diff --git a/.env.example b/.env.example
index ef53299d..e7d1921f 100644
--- a/.env.example
+++ b/.env.example
@@ -24,6 +24,13 @@ NODE_ENV=development
# SERVE_DASHBOARD=true # dashboard at /
# SERVE_BOOKING=true # booking engine at /booking/
+# Optional booking-requests module (STR / request-first direct booking).
+# When false (default), request-mode tables, routes, and Stripe handlers are not loaded.
+# HAIP_BOOKING_REQUESTS=false
+
+# Dashboard / booking widget: set true when the API runs with HAIP_BOOKING_REQUESTS=true
+# VITE_HAIP_BOOKING_REQUESTS=false
+
# In production the API refuses to boot with an insecure config
# (AUTH_ENABLED=false or STRIPE_MODE=mock) to prevent an accidental insecure
# real deployment. The intentional public demo sets this to opt out.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3117ed2a..5b83a820 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,7 +26,7 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Build packages
- run: pnpm -r --filter @telivityhaip/shared --filter @telivityhaip/database run build
+ run: pnpm -r --filter @telivityhaip/shared --filter @telivityhaip/database --filter @telivityhaip/booking-requests run build
- name: Lint
run: pnpm lint
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index de0f99c6..f6335993 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -73,9 +73,71 @@ jobs:
DATABASE_URL: postgresql://haip:haip@localhost:5432/haip_test
REDIS_URL: redis://localhost:6379
+ ci-booking-requests:
+ name: CI (booking-requests)
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: haip
+ POSTGRES_PASSWORD: haip
+ POSTGRES_DB: haip_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ redis:
+ image: redis:7-alpine
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: pnpm/action-setup@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: 'pnpm'
+
+ - run: pnpm install --frozen-lockfile
+
+ - name: Build all packages
+ run: pnpm build
+
+ - name: Push core database schema
+ run: pnpm db:migrate
+ env:
+ DATABASE_URL: postgresql://haip:haip@localhost:5432/haip_test
+
+ - name: Push booking-requests schema
+ run: pnpm db:migrate:booking-requests
+ env:
+ DATABASE_URL: postgresql://haip:haip@localhost:5432/haip_test
+
+ - name: Run booking-requests release gate
+ run: pnpm --filter @telivityhaip/api exec vitest run src/modules/booking-request/booking-request-default-flow-regression.spec.ts
+ env:
+ DATABASE_URL: postgresql://haip:haip@localhost:5432/haip_test
+ REDIS_URL: redis://localhost:6379
+ HAIP_BOOKING_REQUESTS: 'true'
+ AUTH_ENABLED: 'false'
+
release:
name: Auto Release
- needs: ci
+ needs: [ci, ci-booking-requests]
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.version.outputs.skip }}
@@ -205,6 +267,7 @@ jobs:
VITE_KEYCLOAK_URL=http://localhost:8080
VITE_KEYCLOAK_REALM=haip
VITE_KEYCLOAK_CLIENT_ID=haip-dashboard
+ VITE_HAIP_BOOKING_REQUESTS=false
tags: |
ghcr.io/telivityai/haip-api:${{ needs.release.outputs.next }}
ghcr.io/telivityai/haip-api:latest
diff --git a/README.md b/README.md
index 7eb670b2..5e521ab2 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 (1635 passing tests across 229 files with passing tests) | Unit and integration tests |
+| Testing | Vitest (2222 passing tests across 266 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 |
@@ -643,7 +643,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests
```bash
-# Passing-test count: 1635 test cases across 229 files (skipped excluded)
+# Passing-test count: 2222 test cases across 266 files (skipped excluded)
# API tests only
pnpm --filter @telivityhaip/api test
@@ -1191,7 +1191,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 (1635 passing, 229 files with passes; skipped excluded)
+pnpm test # Run all tests (2222 passing, 266 files with passes; skipped excluded)
pnpm lint # ESLint
```
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
index 4f92b53f..9df42235 100644
--- a/apps/api/Dockerfile
+++ b/apps/api/Dockerfile
@@ -11,6 +11,7 @@ COPY apps/dashboard/package.json ./apps/dashboard/
COPY apps/booking/package.json ./apps/booking/
COPY packages/database/package.json ./packages/database/
COPY packages/shared/package.json* ./packages/shared/
+COPY packages/booking-requests/package.json ./packages/booking-requests/
RUN pnpm install --frozen-lockfile || pnpm install
@@ -24,16 +25,21 @@ COPY apps/booking/ ./apps/booking/
# Build workspace packages
RUN pnpm --filter @telivityhaip/shared run build
RUN pnpm --filter @telivityhaip/database run build
+RUN pnpm --filter @telivityhaip/booking-requests run build
-# Dashboard SPA — auth/Keycloak settings are baked at build time (Vite env).
+# Dashboard/booking SPAs — auth/Keycloak/feature flags are baked at build time (Vite env).
ARG VITE_AUTH_ENABLED=false
ARG VITE_KEYCLOAK_URL=http://localhost:8080
ARG VITE_KEYCLOAK_REALM=haip
ARG VITE_KEYCLOAK_CLIENT_ID=haip-dashboard
+# Must match the API's HAIP_BOOKING_REQUESTS at deploy time, or the module can
+# be enabled server-side while the UIs compile it out (see booking-engine-config.service.ts).
+ARG VITE_HAIP_BOOKING_REQUESTS=false
ENV VITE_AUTH_ENABLED=$VITE_AUTH_ENABLED \
VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL \
VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM \
- VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID
+ VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID \
+ VITE_HAIP_BOOKING_REQUESTS=$VITE_HAIP_BOOKING_REQUESTS
# Build dashboard (Vite static output)
RUN pnpm --filter dashboard run build
@@ -65,6 +71,17 @@ COPY --from=base /app/packages/database/package.json ./packages/database/
COPY --from=base /app/packages/database/node_modules ./packages/database/node_modules
COPY --from=base /app/packages/shared/dist ./packages/shared/dist
COPY --from=base /app/packages/shared/package.json ./packages/shared/
+# shared now peers on @nestjs/common (access decorators / HTTP exceptions
+# relocated here for the booking-requests package boundary). Without this
+# copy, `require('@nestjs/common')` from packages/shared/dist fails in the
+# production image even though root node_modules is present — pnpm keeps the
+# peer under packages/shared/node_modules.
+COPY --from=base /app/packages/shared/node_modules ./packages/shared/node_modules
+COPY --from=base /app/packages/booking-requests/dist ./packages/booking-requests/dist
+COPY --from=base /app/packages/booking-requests/package.json ./packages/booking-requests/
+# booking-requests' own node_modules (postgres) — needed so `node
+# dist/database/migrate.js` can run standalone, matching packages/database.
+COPY --from=base /app/packages/booking-requests/node_modules ./packages/booking-requests/node_modules
ENV NODE_ENV=production
diff --git a/apps/api/package.json b/apps/api/package.json
index 67ea9504..accdb0f2 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -12,6 +12,7 @@
"lint": "eslint src/",
"typecheck": "tsc --noEmit -p tsconfig.build.json",
"test": "vitest run",
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:release-smoke": "RELEASE_SMOKE=1 vitest run src/release-smoke.integration.spec.ts",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
@@ -31,6 +32,7 @@
"@nestjs/websockets": "^10.0.0",
"@telivityhaip/database": "workspace:*",
"@telivityhaip/shared": "workspace:^",
+ "@telivityhaip/booking-requests": "workspace:*",
"@types/jsonwebtoken": "^9.0.10",
"bullmq": "^5.81.1",
"class-transformer": "^0.5.1",
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index 2b00fcf7..bc87c17e 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -53,13 +53,18 @@ import { LoyaltyModule } from './modules/loyalty/loyalty.module';
import { IntegrationsModule } from './modules/integrations/integrations.module';
import { IcalModule } from './modules/ical/ical.module';
import { FiscalModule } from './modules/fiscal/fiscal.module';
+import { bookingRequestsModules } from './booking-requests.bootstrap';
const imports: any[] = [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
- EventEmitterModule.forRoot(),
+ // `wildcard: true` is required for ConnectEventsService/EventsService's
+ // `@OnEvent('**')` catch-all listeners (webhook fan-out + the live dashboard
+ // event feed) to receive every emitted event — eventemitter2 defaults to
+ // `wildcard: false`, under which '**' listeners never match anything.
+ EventEmitterModule.forRoot({ wildcard: true, delimiter: '.' }),
DatabaseModule,
HealthModule,
PropertyModule,
@@ -70,6 +75,7 @@ const imports: any[] = [
FolioModule,
RatePlanModule,
PaymentModule,
+ ...bookingRequestsModules(),
HousekeepingModule,
LostAndFoundModule,
ServiceRequestsModule,
diff --git a/apps/api/src/booking-requests.bootstrap.ts b/apps/api/src/booking-requests.bootstrap.ts
new file mode 100644
index 00000000..3dc6d00c
--- /dev/null
+++ b/apps/api/src/booking-requests.bootstrap.ts
@@ -0,0 +1,117 @@
+import type { DynamicModule, Type } from '@nestjs/common';
+
+let cachedModules: Array | null | undefined;
+
+/**
+ * Builds the `@telivityhaip/booking-requests` package's `BookingRequestModule.forRoot(...)`
+ * DynamicModule, binding every port it declares to the concrete core singleton
+ * that satisfies it (mostly `useExisting`). This is the ONLY place core wires
+ * itself to the optional package — the package itself never imports from
+ * `apps/api`.
+ */
+async function buildBookingRequestsModule(): Promise {
+ const [
+ { BookingRequestModule },
+ { AncillaryModule },
+ { AncillaryService },
+ { BookingEngineModule },
+ { BookingEngineService },
+ { BookingEngineConfigService },
+ { BookingKeyGuard },
+ { BookingEngineScopeGuard },
+ { BookingThrottleGuard },
+ { EmailModule },
+ { EmailService },
+ { FolioModule },
+ { FolioService },
+ { GuestModule },
+ { GuestService },
+ { PaymentModule },
+ { RatePlanModule },
+ { RatePlanService },
+ { ReservationModule },
+ { ReservationService },
+ { AvailabilityService },
+ { WebhookModule },
+ { WebhookService },
+ ] = await Promise.all([
+ import('@telivityhaip/booking-requests'),
+ import('./modules/ancillary/ancillary.module.js'),
+ import('./modules/ancillary/ancillary.service.js'),
+ import('./modules/booking-engine/booking-engine.module.js'),
+ import('./modules/booking-engine/booking-engine.service.js'),
+ import('./modules/booking-engine/booking-engine-config.service.js'),
+ import('./modules/auth/booking-key.guard.js'),
+ import('./modules/auth/booking-engine-scope.guard.js'),
+ import('./modules/booking-engine/booking-throttle.guard.js'),
+ import('./modules/agent/guest-comms/email.module.js'),
+ import('./modules/agent/guest-comms/email.service.js'),
+ import('./modules/folio/folio.module.js'),
+ import('./modules/folio/folio.service.js'),
+ import('./modules/guest/guest.module.js'),
+ import('./modules/guest/guest.service.js'),
+ import('./modules/payment/payment.module.js'),
+ import('./modules/rate-plan/rate-plan.module.js'),
+ import('./modules/rate-plan/rate-plan.service.js'),
+ import('./modules/reservation/reservation.module.js'),
+ import('./modules/reservation/reservation.service.js'),
+ import('./modules/reservation/availability.service.js'),
+ import('./modules/webhook/webhook.module.js'),
+ import('./modules/webhook/webhook.service.js'),
+ ]);
+
+ return BookingRequestModule.forRoot({
+ imports: [
+ AncillaryModule,
+ BookingEngineModule,
+ EmailModule,
+ FolioModule,
+ GuestModule,
+ // PaymentModule is required not only for FolioService's payment side
+ // effects but so SAVED_PAYMENT_METHOD_GATEWAY / PAYMENT_GATEWAY — the
+ // exact same @telivityhaip/shared tokens on both sides of the package
+ // boundary — resolve without this module re-binding them.
+ PaymentModule,
+ RatePlanModule,
+ ReservationModule,
+ WebhookModule,
+ ],
+ ancillaryService: { useExisting: AncillaryService },
+ availabilityService: { useExisting: AvailabilityService },
+ bookingEngineService: { useExisting: BookingEngineService },
+ bookingEngineConfigService: { useExisting: BookingEngineConfigService },
+ emailService: { useExisting: EmailService },
+ folioService: { useExisting: FolioService },
+ guestService: { useExisting: GuestService },
+ ratePlanService: { useExisting: RatePlanService },
+ reservationService: { useExisting: ReservationService },
+ webhookService: { useExisting: WebhookService },
+ // Core's BookingEngineController/AuthModule own credential, scope, and
+ // rate-limit enforcement — bind the package's public-controller guard
+ // ports to those same singletons instead of duplicating the logic.
+ bookingKeyGuard: { useExisting: BookingKeyGuard },
+ bookingEngineScopeGuard: { useExisting: BookingEngineScopeGuard },
+ bookingThrottleGuard: { useExisting: BookingThrottleGuard },
+ });
+}
+
+/** Preload the optional booking-requests Nest module when the feature flag is on. */
+export async function preloadBookingRequestsModules(): Promise {
+ if (process.env['HAIP_BOOKING_REQUESTS'] !== 'true') {
+ cachedModules = null;
+ return;
+ }
+ if (cachedModules !== undefined) return;
+
+ cachedModules = [await buildBookingRequestsModule()];
+}
+
+export function bookingRequestsModules(): Array {
+ if (process.env['HAIP_BOOKING_REQUESTS'] !== 'true') return [];
+ if (!cachedModules) {
+ throw new Error(
+ 'Booking requests is enabled but modules were not preloaded — call preloadBookingRequestsModules() before bootstrapping Nest',
+ );
+ }
+ return cachedModules;
+}
diff --git a/apps/api/src/common/accepted-pricing/accepted-reservation-service.ts b/apps/api/src/common/accepted-pricing/accepted-reservation-service.ts
new file mode 100644
index 00000000..63a714c3
--- /dev/null
+++ b/apps/api/src/common/accepted-pricing/accepted-reservation-service.ts
@@ -0,0 +1,49 @@
+import type { AcceptedPricingSnapshot } from '@telivityhaip/database';
+
+export type AcceptedReservationServiceCandidate = {
+ id: string;
+ serviceId: string;
+ status?: string | null;
+ sourceChannel?: string | null;
+ createdAt?: Date | string | null;
+};
+
+function createdAtValue(value: Date | string | null | undefined): number {
+ if (value instanceof Date) return value.getTime();
+ if (typeof value === 'string') {
+ const parsed = Date.parse(value);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return Number.MAX_SAFE_INTEGER;
+}
+
+/**
+ * Match each snapshot service to exactly one operational row. Booking Request
+ * acceptance creates `booking_engine` rows; later front-desk duplicates are
+ * legal extras and must never duplicate or resurrect that accepted component.
+ * The time/id fallback makes legacy rows deterministic when provenance is absent.
+ */
+export function matchAcceptedReservationServiceRows<
+ T extends AcceptedReservationServiceCandidate,
+>(
+ pricing: Pick | null | undefined,
+ rows: readonly T[],
+): Map {
+ const matched = new Map();
+ if (!pricing) return matched;
+
+ for (const service of pricing.services) {
+ const candidates = rows
+ .filter((row) => row.serviceId === service.serviceId)
+ .sort((left, right) => {
+ const provenance = Number(right.sourceChannel === 'booking_engine')
+ - Number(left.sourceChannel === 'booking_engine');
+ if (provenance !== 0) return provenance;
+ const created = createdAtValue(left.createdAt) - createdAtValue(right.createdAt);
+ return created !== 0 ? created : left.id.localeCompare(right.id);
+ });
+ if (candidates[0]) matched.set(service.serviceId, candidates[0]);
+ }
+
+ return matched;
+}
diff --git a/apps/api/src/common/audit/audit-actor.ts b/apps/api/src/common/audit/audit-actor.ts
index a0a46afa..b15c3c14 100644
--- a/apps/api/src/common/audit/audit-actor.ts
+++ b/apps/api/src/common/audit/audit-actor.ts
@@ -1,25 +1,13 @@
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
+import type { AuditActor } from '@telivityhaip/shared';
import type { AuthUser } from '../../modules/auth/current-user.decorator';
-/** Who performed an audited action + from where. All optional (null in AUTH-off demo). */
-export interface AuditActor {
- userId?: string | null;
- userEmail?: string | null;
- ipAddress?: string | null;
-}
-
-/** Map an AuditActor to the auditLogs actor columns (always defined, possibly null). */
-export function actorFields(actor?: AuditActor): {
- userId: string | null;
- userEmail: string | null;
- ipAddress: string | null;
-} {
- return {
- userId: actor?.userId ?? null,
- userEmail: actor?.userEmail ?? null,
- ipAddress: actor?.ipAddress ?? null,
- };
-}
+/**
+ * Canonical definitions live in @telivityhaip/shared so
+ * @telivityhaip/booking-requests can use the same actor shape without
+ * importing apps/api.
+ */
+export { type AuditActor, actorFields } from '@telivityhaip/shared';
/**
* Controller param decorator — builds an AuditActor from the authenticated
diff --git a/apps/api/src/common/database/accepted-pricing-lock.postgres.spec.ts b/apps/api/src/common/database/accepted-pricing-lock.postgres.spec.ts
new file mode 100644
index 00000000..55125819
--- /dev/null
+++ b/apps/api/src/common/database/accepted-pricing-lock.postgres.spec.ts
@@ -0,0 +1,828 @@
+/**
+ * Real-PostgreSQL concurrency proof for `withAcceptedPricingLock` across the
+ * core services (Ancillary/Folio/Reservation/NightAudit/...) AND
+ * `@telivityhaip/booking-requests`'s `BookingRequestService`. Kept in apps/api
+ * (like `modules/booking-request/booking-request-service-transaction-seams.spec.ts`)
+ * because it instantiates every core service class directly — a ports-based
+ * rewrite would replace the exact real-transaction interleaving this test
+ * exists to prove with mocks, defeating its purpose. Importing
+ * `BookingRequestService` FROM the package here is the normal, expected
+ * direction of the package boundary (apps/api MAY depend on the package);
+ * only the reverse is forbidden.
+ */
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import { drizzle } from 'drizzle-orm/postgres-js';
+import { sql } from 'drizzle-orm';
+import postgres from 'postgres';
+import { withAcceptedPricingLock } from './accepted-pricing-lock';
+import { AncillaryService } from '../../modules/ancillary/ancillary.service';
+import { FolioService } from '../../modules/folio/folio.service';
+import { BookingRequestService } from '@telivityhaip/booking-requests';
+import { BookingEngineService } from '../../modules/booking-engine/booking-engine.service';
+import { BookingEngineConfigService } from '../../modules/booking-engine/booking-engine-config.service';
+import { AvailabilityService } from '../../modules/reservation/availability.service';
+import { RatePlanService } from '../../modules/rate-plan/rate-plan.service';
+import { TaxService } from '../../modules/tax/tax.service';
+import { ReservationService } from '../../modules/reservation/reservation.service';
+import { NightAuditService } from '../../modules/night-audit/night-audit.service';
+import { PolicyService } from '../../modules/policy/policy.service';
+
+const live = process.env['ACCEPTED_PRICING_LIVE_PG'] === '1';
+const databaseUrl = process.env['DATABASE_URL'];
+const suite = live && databaseUrl ? describe : describe.skip;
+
+suite('accepted-pricing mutex against PostgreSQL', () => {
+ const client = postgres(databaseUrl!, { max: 8 });
+ const db = drizzle(client);
+ const actualIds = {
+ property: '12000000-0000-4000-a000-000000000001',
+ guest: '12000000-0000-4000-a000-000000000002',
+ roomType: '12000000-0000-4000-a000-000000000003',
+ room: '12000000-0000-4000-a000-000000000014',
+ ratePlan: '12000000-0000-4000-a000-000000000004',
+ booking: '12000000-0000-4000-a000-000000000005',
+ reservation: '12000000-0000-4000-a000-000000000006',
+ folio: '12000000-0000-4000-a000-000000000007',
+ service: '12000000-0000-4000-a000-000000000008',
+ reservationService: '12000000-0000-4000-a000-000000000009',
+ secondProperty: '12000000-0000-4000-a000-000000000010',
+ secondFolio: '12000000-0000-4000-a000-000000000011',
+ baseCharge: '12000000-0000-4000-a000-000000000012',
+ correctionCharge: '12000000-0000-4000-a000-000000000013',
+ bookingRequest: '12000000-0000-4000-a000-000000000015',
+ };
+ const propertyId = actualIds.property;
+ const reservationId = actualIds.reservation;
+
+ beforeAll(async () => {
+ await client.unsafe('DROP SCHEMA IF EXISTS task12_accepted_pricing_lock_test CASCADE');
+ await client.unsafe('CREATE SCHEMA task12_accepted_pricing_lock_test');
+ await client.unsafe(`
+ CREATE TABLE task12_accepted_pricing_lock_test.pricing_state (
+ property_id text NOT NULL,
+ reservation_id text NOT NULL,
+ amount numeric(12,2) NOT NULL,
+ PRIMARY KEY (property_id, reservation_id)
+ )
+ `);
+ await client.unsafe(`
+ CREATE TABLE task12_accepted_pricing_lock_test.ledger (
+ source_key text PRIMARY KEY,
+ amount numeric(12,2) NOT NULL,
+ kind text NOT NULL
+ )
+ `);
+ });
+
+ afterAll(async () => {
+ await cleanupActualServiceFixture();
+ await client.unsafe('DROP SCHEMA IF EXISTS task12_accepted_pricing_lock_test CASCADE');
+ await client.end();
+ });
+
+ async function cleanupActualServiceFixture() {
+ await client.unsafe('DROP TRIGGER IF EXISTS task12_delay_cancel ON reservation_services');
+ await client.unsafe('DROP FUNCTION IF EXISTS task12_delay_cancel()');
+ await client.unsafe('DROP TRIGGER IF EXISTS task12_delay_charge ON charges');
+ await client.unsafe('DROP FUNCTION IF EXISTS task12_delay_charge()');
+ await client.unsafe('DROP TRIGGER IF EXISTS task12_delay_amendment ON reservations');
+ await client.unsafe('DROP FUNCTION IF EXISTS task12_delay_amendment()');
+ await client`
+ DELETE FROM charges
+ WHERE property_id IN (${actualIds.property}, ${actualIds.secondProperty})
+ `;
+ await client`
+ DELETE FROM booking_request_consequences WHERE property_id = ${actualIds.property}
+ `;
+ await client`
+ DELETE FROM booking_request_stay_amendments WHERE property_id = ${actualIds.property}
+ `;
+ await client`DELETE FROM audit_logs WHERE property_id = ${actualIds.property}`;
+ await client`DELETE FROM booking_requests WHERE id = ${actualIds.bookingRequest}`;
+ await client`DELETE FROM reservation_services WHERE id = ${actualIds.reservationService}`;
+ await client`DELETE FROM services WHERE id = ${actualIds.service}`;
+ await client`DELETE FROM booking_engine_config WHERE property_id = ${actualIds.property}`;
+ await client`DELETE FROM folios WHERE id = ${actualIds.secondFolio}`;
+ await client`DELETE FROM folios WHERE id = ${actualIds.folio}`;
+ await client`DELETE FROM reservations WHERE id = ${actualIds.reservation}`;
+ await client`DELETE FROM bookings WHERE id = ${actualIds.booking}`;
+ await client`DELETE FROM rooms WHERE id = ${actualIds.room}`;
+ await client`DELETE FROM rate_plans WHERE id = ${actualIds.ratePlan}`;
+ await client`DELETE FROM room_types WHERE id = ${actualIds.roomType}`;
+ await client`DELETE FROM properties WHERE id = ${actualIds.property}`;
+ await client`DELETE FROM properties WHERE id = ${actualIds.secondProperty}`;
+ await client`DELETE FROM guests WHERE id = ${actualIds.guest}`;
+ }
+
+ async function setupActualServiceFixture() {
+ await cleanupActualServiceFixture();
+ const acceptedPricingSnapshot = {
+ version: 1,
+ source: 'current',
+ currencyCode: 'EUR',
+ grandTotal: '122.00',
+ roomTotal: '100.00',
+ taxTotal: '0.00',
+ nights: [{ date: '2026-10-01', roomAmount: '100.00', taxAmount: '0.00' }],
+ services: [{
+ serviceId: actualIds.service,
+ code: 'T12PARK',
+ name: 'Task 12 parking',
+ postingRule: 'once',
+ chargeType: 'parking',
+ currencyCode: 'EUR',
+ unitPrice: '20.00',
+ quantity: 1,
+ lineTotal: '20.00',
+ taxTotal: '2.00',
+ lineItems: [{ date: '2026-10-01', amount: '20.00', taxAmount: '2.00' }],
+ }],
+ servicesTotal: '20.00',
+ servicesTaxTotal: '2.00',
+ customReason: null,
+ adjustment: null,
+ };
+ await client`
+ INSERT INTO properties
+ (id, name, code, country_code, timezone, currency_code, total_rooms)
+ VALUES
+ (${actualIds.property}, 'Task 12 race', 'T12RACE', 'ES', 'Europe/Madrid', 'EUR', 1)
+ `;
+ await client`
+ INSERT INTO guests (id, first_name, last_name)
+ VALUES (${actualIds.guest}, 'Task', 'Twelve')
+ `;
+ await client`
+ INSERT INTO room_types
+ (id, property_id, name, code, max_occupancy, default_occupancy)
+ VALUES
+ (${actualIds.roomType}, ${actualIds.property}, 'Race room', 'T12ROOM', 2, 1)
+ `;
+ await client`
+ INSERT INTO rate_plans
+ (id, property_id, room_type_id, name, code, type, base_amount, currency_code)
+ VALUES
+ (${actualIds.ratePlan}, ${actualIds.property}, ${actualIds.roomType},
+ 'Task 12 race', 'T12RATE', 'bar', 100.00, 'EUR')
+ `;
+ await client`
+ INSERT INTO rooms (id, property_id, room_type_id, number, status)
+ VALUES (${actualIds.room}, ${actualIds.property}, ${actualIds.roomType}, 'T12-101', 'occupied')
+ `;
+ await client`
+ INSERT INTO bookings
+ (id, property_id, guest_id, confirmation_number, source)
+ VALUES
+ (${actualIds.booking}, ${actualIds.property}, ${actualIds.guest}, 'T12-RACE-CONF', 'direct')
+ `;
+ await client`
+ INSERT INTO reservations
+ (id, property_id, booking_id, guest_id, arrival_date, departure_date, nights,
+ room_type_id, status, rate_plan_id, total_amount, currency_code,
+ accepted_pricing_snapshot)
+ VALUES
+ (${actualIds.reservation}, ${actualIds.property}, ${actualIds.booking}, ${actualIds.guest},
+ '2026-10-01', '2026-10-02', 1, ${actualIds.roomType}, 'checked_in',
+ ${actualIds.ratePlan}, 122.00, 'EUR', ${JSON.stringify(acceptedPricingSnapshot)}::jsonb)
+ `;
+ await client`
+ INSERT INTO folios
+ (id, property_id, reservation_id, booking_id, guest_id, folio_number,
+ type, status, currency_code)
+ VALUES
+ (${actualIds.folio}, ${actualIds.property}, ${actualIds.reservation},
+ ${actualIds.booking}, ${actualIds.guest}, 'T12-RACE-FOLIO', 'guest', 'open', 'EUR')
+ `;
+ await client`
+ INSERT INTO services
+ (id, property_id, code, name, charge_type, price, currency_code,
+ posting_rule, sell_channels)
+ VALUES
+ (${actualIds.service}, ${actualIds.property}, 'T12PARK', 'Task 12 parking',
+ 'parking', 20.00, 'EUR', 'once', ${JSON.stringify(['booking_engine'])}::jsonb)
+ `;
+ await client`
+ INSERT INTO reservation_services
+ (id, property_id, reservation_id, service_id, quantity, unit_price,
+ currency_code, status, source_channel, posting_rule, charge_type)
+ VALUES
+ (${actualIds.reservationService}, ${actualIds.property}, ${actualIds.reservation},
+ ${actualIds.service}, 1, 20.00, 'EUR', 'confirmed', 'booking_engine', 'once', 'parking')
+ `;
+ await client`
+ INSERT INTO booking_engine_config
+ (property_id, is_enabled, booking_mode, sellable_room_type_ids,
+ sellable_rate_plan_ids, deposit_policy)
+ VALUES
+ (${actualIds.property}, true, 'request', ${JSON.stringify([actualIds.roomType])}::jsonb,
+ ${JSON.stringify([actualIds.ratePlan])}::jsonb,
+ ${JSON.stringify({ type: 'none', refundable: true })}::jsonb)
+ `;
+ await client`
+ INSERT INTO booking_requests
+ (id, property_id, submission_idempotency_key, submission_fingerprint,
+ status, arrival_date, departure_date, room_type_id, rate_plan_id,
+ adults, children, guest_first_name, guest_last_name, guest_email,
+ service_ids, submitted_quote_snapshot, currency_code,
+ submitted_total,
+ accepted_price_source, accepted_total, accepted_reservation_id,
+ accepted_folio_id, decided_at)
+ VALUES
+ (${actualIds.bookingRequest}, ${actualIds.property}, 'task12-live-amendment',
+ ${'a'.repeat(64)}, 'accepted', '2026-10-01', '2026-10-02',
+ ${actualIds.roomType}, ${actualIds.ratePlan}, 1, 0, 'Task', 'Twelve',
+ 'task12@example.invalid', ${JSON.stringify([actualIds.service])}::jsonb,
+ ${JSON.stringify(acceptedPricingSnapshot)}::jsonb, 'EUR', 122.00, 'current', 122.00,
+ ${actualIds.reservation}, ${actualIds.folio}, now())
+ `;
+ }
+
+ async function waitForTriggerSleep() {
+ for (let attempt = 0; attempt < 100; attempt++) {
+ const [row] = await client<{ count: number }[]>`
+ SELECT count(*)::int AS count
+ FROM pg_stat_activity
+ WHERE datname = current_database()
+ AND pid <> pg_backend_pid()
+ AND wait_event = 'PgSleep'
+ `;
+ if ((row?.count ?? 0) > 0) return;
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+ throw new Error('accepted-pricing fixture delay was not observed');
+ }
+
+ async function reset() {
+ await setupActualServiceFixture();
+ await client.unsafe('TRUNCATE task12_accepted_pricing_lock_test.ledger');
+ await client.unsafe('TRUNCATE task12_accepted_pricing_lock_test.pricing_state');
+ await client`
+ INSERT INTO task12_accepted_pricing_lock_test.pricing_state
+ (property_id, reservation_id, amount)
+ VALUES (${propertyId}, ${reservationId}, 100.00)
+ `;
+ }
+
+ function actualServiceGraph() {
+ const webhook = {
+ emit: vi.fn().mockResolvedValue(undefined),
+ dispatchPersisted: vi.fn().mockResolvedValue(undefined),
+ };
+ const tax = new TaxService(db as any);
+ const folio = new FolioService(db as any, webhook as any, tax);
+ const ancillary = new AncillaryService(db as any, folio, webhook as any);
+ const availability = new AvailabilityService(db as any);
+ const ratePlan = new RatePlanService(db as any, webhook as any);
+ const policy = new PolicyService(db as any, webhook as any);
+ const reservation = new ReservationService(
+ db as any,
+ availability,
+ folio,
+ {} as any,
+ {} as any,
+ webhook as any,
+ ancillary,
+ policy,
+ {} as any,
+ ratePlan,
+ );
+ const config = new BookingEngineConfigService(db as any, {
+ get: (key: string, fallback?: string) => key === 'PAYMENT_GATEWAY' ? 'mock' : fallback,
+ } as any);
+ const bookingEngine = new BookingEngineService(
+ db as any,
+ {} as any,
+ {} as any,
+ reservation,
+ availability,
+ ratePlan,
+ tax,
+ {} as any,
+ folio,
+ {} as any,
+ {} as any,
+ config,
+ ancillary,
+ policy,
+ );
+ const bookingRequest = new BookingRequestService(
+ db as any,
+ config,
+ bookingEngine,
+ availability,
+ ratePlan,
+ {} as any,
+ webhook as any,
+ {} as any,
+ reservation,
+ folio,
+ ancillary,
+ {} as any,
+ );
+ const nightAudit = new NightAuditService(
+ db as any,
+ folio,
+ reservation,
+ {} as any,
+ {} as any,
+ webhook as any,
+ ancillary,
+ policy,
+ {} as any,
+ );
+ return { ancillary, bookingRequest, folio, nightAudit, webhook };
+ }
+
+ it('forces a posting path to re-read the amended snapshot before claiming its source', async () => {
+ await reset();
+ let releaseAmendment!: () => void;
+ let amendmentWritten!: () => void;
+ const holdAmendment = new Promise((resolve) => { releaseAmendment = resolve; });
+ const written = new Promise((resolve) => { amendmentWritten = resolve; });
+
+ const amendment = withAcceptedPricingLock(
+ db,
+ propertyId,
+ reservationId,
+ async (tx) => {
+ await tx.execute(sql`
+ UPDATE task12_accepted_pricing_lock_test.pricing_state
+ SET amount = 80.00
+ WHERE property_id = ${propertyId} AND reservation_id = ${reservationId}
+ `);
+ amendmentWritten();
+ await holdAmendment;
+ },
+ );
+ await written;
+
+ let postingEntered = false;
+ const posting = withAcceptedPricingLock(
+ db,
+ propertyId,
+ reservationId,
+ async (tx) => {
+ postingEntered = true;
+ const rows = await tx.execute(sql<{ amount: string }>`
+ SELECT amount::text AS amount
+ FROM task12_accepted_pricing_lock_test.pricing_state
+ WHERE property_id = ${propertyId} AND reservation_id = ${reservationId}
+ `);
+ await tx.execute(sql`
+ INSERT INTO task12_accepted_pricing_lock_test.ledger (source_key, amount, kind)
+ VALUES ('canonical', ${rows[0]!.amount}, 'canonical')
+ ON CONFLICT (source_key) DO NOTHING
+ `);
+ },
+ );
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(postingEntered).toBe(false);
+
+ releaseAmendment();
+ await Promise.all([amendment, posting]);
+
+ const [row] = await client<{ amount: string }[]>`
+ SELECT amount::text AS amount FROM task12_accepted_pricing_lock_test.ledger
+ WHERE source_key = 'canonical'
+ `;
+ expect(row?.amount).toBe('80.00');
+ }, 10_000);
+
+ it('serializes the opposite race and reconciles a claimed old group to the new total', async () => {
+ await reset();
+ let releasePosting!: () => void;
+ let oldGroupClaimed!: () => void;
+ const holdPosting = new Promise((resolve) => { releasePosting = resolve; });
+ const claimed = new Promise((resolve) => { oldGroupClaimed = resolve; });
+
+ const posting = withAcceptedPricingLock(
+ db,
+ propertyId,
+ reservationId,
+ async (tx) => {
+ const rows = await tx.execute(sql<{ amount: string }>`
+ SELECT amount::text AS amount
+ FROM task12_accepted_pricing_lock_test.pricing_state
+ WHERE property_id = ${propertyId} AND reservation_id = ${reservationId}
+ `);
+ await tx.execute(sql`
+ INSERT INTO task12_accepted_pricing_lock_test.ledger (source_key, amount, kind)
+ VALUES ('canonical', ${rows[0]!.amount}, 'canonical')
+ `);
+ oldGroupClaimed();
+ await holdPosting;
+ },
+ );
+ await claimed;
+
+ let amendmentEntered = false;
+ const amendment = withAcceptedPricingLock(
+ db,
+ propertyId,
+ reservationId,
+ async (tx) => {
+ amendmentEntered = true;
+ await tx.execute(sql`
+ UPDATE task12_accepted_pricing_lock_test.pricing_state
+ SET amount = 80.00
+ WHERE property_id = ${propertyId} AND reservation_id = ${reservationId}
+ `);
+ const rows = await tx.execute(sql<{ posted: string }>`
+ SELECT coalesce(sum(amount), 0)::text AS posted
+ FROM task12_accepted_pricing_lock_test.ledger
+ `);
+ await tx.execute(sql`
+ INSERT INTO task12_accepted_pricing_lock_test.ledger (source_key, amount, kind)
+ VALUES ('amendment:1', 80.00 - ${rows[0]!.posted}::numeric, 'amendment-adjustment')
+ `);
+ },
+ );
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(amendmentEntered).toBe(false);
+
+ releasePosting();
+ await Promise.all([posting, amendment]);
+
+ const [row] = await client<{ total: string; reversals: number }[]>`
+ SELECT sum(amount)::text AS total,
+ count(*) FILTER (WHERE kind = 'reversal')::int AS reversals
+ FROM task12_accepted_pricing_lock_test.ledger
+ `;
+ expect(row).toEqual({ total: '80.00', reversals: 0 });
+ }, 10_000);
+
+ it('runs the real amendment and night-audit write seams without claiming a stale room group', async () => {
+ await setupActualServiceFixture();
+ const { bookingRequest, nightAudit } = actualServiceGraph();
+ await client`
+ UPDATE rate_plans SET base_amount = 80.00
+ WHERE id = ${actualIds.ratePlan} AND property_id = ${actualIds.property}
+ `;
+ const dates = { arrivalDate: '2026-10-01', departureDate: '2026-10-02' };
+ const preview = await bookingRequest.stayAmendmentPreview(
+ actualIds.bookingRequest,
+ actualIds.property,
+ { propertyId: actualIds.property, ...dates },
+ );
+
+ await client.unsafe(`
+ CREATE OR REPLACE FUNCTION task12_delay_amendment() RETURNS trigger AS $$
+ BEGIN
+ IF NEW.id = '${actualIds.reservation}'::uuid
+ AND NEW.accepted_pricing_snapshot IS DISTINCT FROM OLD.accepted_pricing_snapshot THEN
+ PERFORM pg_sleep(0.35);
+ END IF;
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql
+ `);
+ await client.unsafe(`
+ CREATE TRIGGER task12_delay_amendment BEFORE UPDATE ON reservations
+ FOR EACH ROW EXECUTE FUNCTION task12_delay_amendment()
+ `);
+
+ const amendment = bookingRequest.amendStay(
+ actualIds.bookingRequest,
+ actualIds.property,
+ {
+ ...dates,
+ priceSource: 'current',
+ previewToken: preview.previewToken,
+ idempotencyKey: 'task12-live-amend-vs-audit',
+ },
+ { userEmail: 'night.manager@example.invalid' },
+ );
+ await waitForTriggerSleep();
+ const tariffPosting = nightAudit.postRoomTariffs(actualIds.property, '2026-10-01');
+
+ const [amended, tariff] = await Promise.all([amendment, tariffPosting]);
+ expect(amended).toMatchObject({
+ previousTotalAmount: '122.00',
+ newTotalAmount: '100.00',
+ priceSource: 'current',
+ });
+ expect(tariff).toMatchObject({ totalRoom: '80.00', totalTax: '0.00', count: 1, errors: [] });
+
+ const roomLedger = await client<{ amount: string; sourceKey: string }[]>`
+ SELECT amount::text AS amount, source_key AS "sourceKey"
+ FROM charges
+ WHERE property_id = ${actualIds.property} AND type = 'room'
+ `;
+ expect(roomLedger).toEqual([{
+ amount: '80.00',
+ sourceKey: `accepted-pricing:reservation:${actualIds.reservation}:night:2026-10-01`,
+ }]);
+ await expect(
+ nightAudit.postRoomTariffs(actualIds.property, '2026-10-01'),
+ ).resolves.toMatchObject({ count: 0, errors: [] });
+ const [roomCount] = await client<{ count: number }[]>`
+ SELECT count(*)::int AS count FROM charges
+ WHERE property_id = ${actualIds.property} AND type = 'room'
+ `;
+ expect(roomCount?.count).toBe(1);
+ }, 30_000);
+
+ it('reconciles a room group claimed by real night audit before the real amendment', async () => {
+ await setupActualServiceFixture();
+ const { bookingRequest, nightAudit, webhook } = actualServiceGraph();
+ await client`
+ UPDATE rate_plans SET base_amount = 80.00
+ WHERE id = ${actualIds.ratePlan} AND property_id = ${actualIds.property}
+ `;
+ const dates = { arrivalDate: '2026-10-01', departureDate: '2026-10-02' };
+ const preview = await bookingRequest.stayAmendmentPreview(
+ actualIds.bookingRequest,
+ actualIds.property,
+ { propertyId: actualIds.property, ...dates },
+ );
+ const amendmentInput = {
+ ...dates,
+ priceSource: 'current' as const,
+ previewToken: preview.previewToken,
+ idempotencyKey: 'task12-live-audit-vs-amend',
+ };
+
+ await client.unsafe(`
+ CREATE OR REPLACE FUNCTION task12_delay_charge() RETURNS trigger AS $$
+ BEGIN
+ IF NEW.source_key = 'accepted-pricing:reservation:${actualIds.reservation}:night:2026-10-01' THEN
+ PERFORM pg_sleep(0.35);
+ END IF;
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql
+ `);
+ await client.unsafe(`
+ CREATE TRIGGER task12_delay_charge BEFORE INSERT ON charges
+ FOR EACH ROW EXECUTE FUNCTION task12_delay_charge()
+ `);
+
+ const tariffPosting = nightAudit.postRoomTariffs(actualIds.property, '2026-10-01');
+ await waitForTriggerSleep();
+ const amendment = bookingRequest.amendStay(
+ actualIds.bookingRequest,
+ actualIds.property,
+ amendmentInput,
+ { userEmail: 'night.manager@example.invalid' },
+ );
+
+ const [tariff, amended] = await Promise.all([tariffPosting, amendment]);
+ expect(tariff).toMatchObject({
+ totalRoom: '100.00',
+ totalTax: '0.00',
+ count: 1,
+ errors: [],
+ });
+ expect(amended).toMatchObject({
+ previousTotalAmount: '122.00',
+ newTotalAmount: '100.00',
+ priceSource: 'current',
+ });
+
+ const roomLedger = await client<{
+ id: string;
+ amount: string;
+ isReversal: boolean;
+ sourceKey: string | null;
+ adjustsChargeId: string | null;
+ parentChargeId: string | null;
+ }[]>`
+ SELECT id, amount::text AS amount, is_reversal AS "isReversal",
+ source_key AS "sourceKey", adjusts_charge_id AS "adjustsChargeId",
+ parent_charge_id AS "parentChargeId"
+ FROM charges
+ WHERE property_id = ${actualIds.property} AND type = 'room'
+ ORDER BY created_at, id
+ `;
+ expect(roomLedger).toHaveLength(2);
+ const base = roomLedger.find((row) => row.adjustsChargeId == null);
+ const correction = roomLedger.find((row) => row.adjustsChargeId != null);
+ expect(base).toMatchObject({
+ amount: '100.00',
+ isReversal: false,
+ sourceKey: `accepted-pricing:reservation:${actualIds.reservation}:night:2026-10-01`,
+ parentChargeId: null,
+ });
+ expect(correction).toMatchObject({
+ amount: '-20.00',
+ isReversal: false,
+ adjustsChargeId: base?.id,
+ parentChargeId: base?.id,
+ });
+ expect(correction?.sourceKey).toContain(
+ `accepted-pricing:reservation:${actualIds.reservation}:amendment:${amended.amendmentId}`,
+ );
+ expect(roomLedger.reduce((sum, row) => sum + Number(row.amount), 0)).toBe(80);
+
+ const replay = await bookingRequest.amendStay(
+ actualIds.bookingRequest,
+ actualIds.property,
+ amendmentInput,
+ { userEmail: 'night.manager@example.invalid' },
+ );
+ expect(replay.amendmentId).toBe(amended.amendmentId);
+ await expect(
+ nightAudit.postRoomTariffs(actualIds.property, '2026-10-01'),
+ ).resolves.toMatchObject({ count: 0, errors: [] });
+
+ const [effects] = await client<{
+ ledgerCount: number;
+ reversals: number;
+ amendmentCount: number;
+ auditCount: number;
+ consequenceCount: number;
+ completedConsequences: number;
+ }[]>`
+ SELECT
+ (SELECT count(*)::int FROM charges
+ WHERE property_id = ${actualIds.property} AND type = 'room') AS "ledgerCount",
+ (SELECT count(*)::int FROM charges
+ WHERE property_id = ${actualIds.property} AND is_reversal) AS reversals,
+ (SELECT count(*)::int FROM booking_request_stay_amendments
+ WHERE property_id = ${actualIds.property}
+ AND booking_request_id = ${actualIds.bookingRequest}) AS "amendmentCount",
+ (SELECT count(*)::int FROM audit_logs
+ WHERE property_id = ${actualIds.property}
+ AND booking_request_id = ${actualIds.bookingRequest}
+ AND description = 'Accepted Booking Request stay amended') AS "auditCount",
+ (SELECT count(*)::int FROM booking_request_consequences
+ WHERE property_id = ${actualIds.property}
+ AND booking_request_id = ${actualIds.bookingRequest}
+ AND kind LIKE 'amend:%') AS "consequenceCount",
+ (SELECT count(*)::int FROM booking_request_consequences
+ WHERE property_id = ${actualIds.property}
+ AND booking_request_id = ${actualIds.bookingRequest}
+ AND kind LIKE 'amend:%' AND status = 'completed') AS "completedConsequences"
+ `;
+ expect(effects).toEqual({
+ ledgerCount: 2,
+ reversals: 0,
+ amendmentCount: 1,
+ auditCount: 1,
+ consequenceCount: 1,
+ completedConsequences: 1,
+ });
+ expect(webhook.dispatchPersisted).toHaveBeenCalledTimes(1);
+ }, 30_000);
+
+ it('serializes the real ancillary posting and cancellation service seams', async () => {
+ await setupActualServiceFixture();
+ const webhookService = { emit: vi.fn().mockResolvedValue(undefined) };
+ const taxService = { calculateTaxes: vi.fn().mockResolvedValue([]) };
+ const folioService = new FolioService(
+ db as any,
+ webhookService as any,
+ taxService as any,
+ );
+ const ancillary = new AncillaryService(db as any, folioService, webhookService as any);
+
+ await client.unsafe(`
+ CREATE OR REPLACE FUNCTION task12_delay_cancel() RETURNS trigger AS $$
+ BEGIN
+ IF NEW.id = '${actualIds.reservationService}'::uuid AND NEW.status = 'cancelled' THEN
+ PERFORM pg_sleep(0.35);
+ END IF;
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql
+ `);
+ await client.unsafe(`
+ CREATE TRIGGER task12_delay_cancel BEFORE UPDATE ON reservation_services
+ FOR EACH ROW EXECUTE FUNCTION task12_delay_cancel()
+ `);
+
+ const cancellation = ancillary.cancelReservationService(
+ actualIds.reservationService,
+ actualIds.property,
+ actualIds.reservation,
+ );
+ await waitForTriggerSleep();
+ const stalePosting = ancillary.postOnceForReservation(
+ actualIds.reservation,
+ actualIds.property,
+ );
+ const [cancelled, postResult] = await Promise.all([cancellation, stalePosting]);
+
+ expect(cancelled.status).toBe('cancelled');
+ expect(postResult.count).toBe(0);
+ const [cancelFirstLedger] = await client<{ count: number }[]>`
+ SELECT count(*)::int AS count FROM charges
+ WHERE property_id = ${actualIds.property}
+ AND source_key LIKE 'accepted-pricing:reservation-service:%'
+ `;
+ expect(cancelFirstLedger?.count).toBe(0);
+
+ await client.unsafe('DROP TRIGGER task12_delay_cancel ON reservation_services');
+ await client.unsafe('DROP FUNCTION task12_delay_cancel()');
+ await client`
+ UPDATE reservation_services SET status = 'confirmed'
+ WHERE id = ${actualIds.reservationService}
+ `;
+
+ await client.unsafe(`
+ CREATE OR REPLACE FUNCTION task12_delay_charge() RETURNS trigger AS $$
+ BEGIN
+ IF NEW.source_key LIKE 'accepted-pricing:reservation-service:%' THEN
+ PERFORM pg_sleep(0.35);
+ END IF;
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql
+ `);
+ await client.unsafe(`
+ CREATE TRIGGER task12_delay_charge BEFORE INSERT ON charges
+ FOR EACH ROW EXECUTE FUNCTION task12_delay_charge()
+ `);
+
+ const posting = ancillary.postOnceForReservation(
+ actualIds.reservation,
+ actualIds.property,
+ );
+ await waitForTriggerSleep();
+ const losingCancellation = ancillary.cancelReservationService(
+ actualIds.reservationService,
+ actualIds.property,
+ actualIds.reservation,
+ );
+
+ await expect(posting).resolves.toMatchObject({ count: 1 });
+ await expect(losingCancellation).rejects.toThrow(/posted reservation service/i);
+ const [finalRow] = await client<{ status: string }[]>`
+ SELECT status::text AS status FROM reservation_services
+ WHERE id = ${actualIds.reservationService}
+ `;
+ expect(finalRow?.status).toBe('posted');
+ const ledger = await client<{
+ id: string;
+ type: string;
+ amount: string;
+ sourceKey: string | null;
+ parentChargeId: string | null;
+ }[]>`
+ SELECT id, type::text AS type, amount::text AS amount,
+ source_key AS "sourceKey", parent_charge_id AS "parentChargeId"
+ FROM charges
+ WHERE property_id = ${actualIds.property}
+ ORDER BY type
+ `;
+ expect(ledger).toHaveLength(2);
+ const base = ledger.find((row) => row.type === 'parking');
+ const tax = ledger.find((row) => row.type === 'tax');
+ expect(base).toMatchObject({
+ amount: '20.00',
+ sourceKey: `accepted-pricing:reservation-service:${actualIds.reservationService}:once:2026-10-01`,
+ parentChargeId: null,
+ });
+ expect(tax).toMatchObject({
+ amount: '2.00',
+ sourceKey: null,
+ parentChargeId: base?.id,
+ });
+ }, 20_000);
+
+ it('enforces accepted correction provenance inside one property in PostgreSQL', async () => {
+ await setupActualServiceFixture();
+ await client`
+ INSERT INTO properties
+ (id, name, code, country_code, timezone, currency_code, total_rooms)
+ VALUES
+ (${actualIds.secondProperty}, 'Task 12 second tenant', 'T12RACE2',
+ 'ES', 'Europe/Madrid', 'EUR', 1)
+ `;
+ await client`
+ INSERT INTO folios
+ (id, property_id, guest_id, folio_number, type, status, currency_code)
+ VALUES
+ (${actualIds.secondFolio}, ${actualIds.secondProperty}, ${actualIds.guest},
+ 'T12-RACE-SECOND', 'guest', 'open', 'EUR')
+ `;
+ await client`
+ INSERT INTO charges
+ (id, property_id, folio_id, type, description, amount, currency_code,
+ service_date, is_reversal)
+ VALUES
+ (${actualIds.baseCharge}, ${actualIds.property}, ${actualIds.folio}, 'room',
+ 'Task 12 base', 100.00, 'EUR', '2026-10-01', false)
+ `;
+
+ await expect(client`
+ INSERT INTO charges
+ (id, property_id, folio_id, type, description, amount, currency_code,
+ service_date, is_reversal, adjusts_charge_id)
+ VALUES
+ (${actualIds.correctionCharge}, ${actualIds.secondProperty}, ${actualIds.secondFolio},
+ 'adjustment', 'Cross-property correction', -20.00, 'EUR', '2026-10-01',
+ false, ${actualIds.baseCharge})
+ `).rejects.toThrow(/charges_adjusts_charge_property_fkey/i);
+
+ const [constraint] = await client<{ definition: string }[]>`
+ SELECT pg_get_constraintdef(oid) AS definition
+ FROM pg_constraint
+ WHERE conname = 'charges_adjusts_charge_property_fkey'
+ AND conrelid = 'charges'::regclass
+ `;
+ expect(constraint?.definition).toContain(
+ 'FOREIGN KEY (property_id, adjusts_charge_id) REFERENCES charges(property_id, id)',
+ );
+ }, 10_000);
+});
diff --git a/apps/api/src/common/database/accepted-pricing-lock.spec.ts b/apps/api/src/common/database/accepted-pricing-lock.spec.ts
new file mode 100644
index 00000000..617188f0
--- /dev/null
+++ b/apps/api/src/common/database/accepted-pricing-lock.spec.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it, vi } from 'vitest';
+import { withAcceptedPricingLock } from './accepted-pricing-lock';
+
+function predicateValues(value: any, values: unknown[] = []) {
+ if (!value || typeof value !== 'object') return values;
+ if ('value' in value) {
+ if (Array.isArray(value.value)) values.push(...value.value);
+ else values.push(value.value);
+ }
+ if (Array.isArray(value.queryChunks)) {
+ for (const chunk of value.queryChunks) predicateValues(chunk, values);
+ }
+ return values;
+}
+
+function lockHarness(rows: Array<{ id: string; propertyId: string }> = [{
+ id: '22222222-2222-4222-8222-222222222222',
+ propertyId: '11111111-1111-4111-8111-111111111111',
+}]) {
+ const order: string[] = [];
+ const tx = {
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn((predicate) => ({
+ for: vi.fn(async () => {
+ order.push('lock');
+ const values = predicateValues(predicate);
+ return rows.filter((row) => (
+ values.includes(row.id) && values.includes(row.propertyId)
+ ));
+ }),
+ })),
+ })),
+ })),
+ };
+ const db = {
+ transaction: vi.fn(async (work: (transaction: typeof tx) => Promise) => work(tx)),
+ };
+ return { db, tx, order };
+}
+
+describe('withAcceptedPricingLock', () => {
+ it('locks the scoped reservation row before running work', async () => {
+ const harness = lockHarness();
+
+ const result = await withAcceptedPricingLock(
+ harness.db,
+ '11111111-1111-4111-8111-111111111111',
+ '22222222-2222-4222-8222-222222222222',
+ async (transaction) => {
+ expect(transaction).toBe(harness.tx);
+ harness.order.push('work');
+ return 'done';
+ },
+ );
+
+ expect(result).toBe('done');
+ expect(harness.order).toEqual(['lock', 'work']);
+ });
+
+ it('does not run work when the reservation is absent from the property', async () => {
+ const harness = lockHarness();
+ let workRan = false;
+
+ await expect(withAcceptedPricingLock(
+ harness.db,
+ '33333333-3333-4333-8333-333333333333',
+ '22222222-2222-4222-8222-222222222222',
+ async () => {
+ workRan = true;
+ },
+ )).rejects.toThrow(/reservation .* not found.*accepted-pricing lock/i);
+
+ expect(workRan).toBe(false);
+ });
+
+ it('reuses a caller transaction instead of nesting another transaction', async () => {
+ const harness = lockHarness();
+
+ const result = await withAcceptedPricingLock(
+ harness.db,
+ '11111111-1111-4111-8111-111111111111',
+ '22222222-2222-4222-8222-222222222222',
+ async (transaction) => transaction,
+ harness.tx,
+ );
+
+ expect(result).toBe(harness.tx);
+ expect(harness.db.transaction).not.toHaveBeenCalled();
+ expect(harness.order).toEqual(['lock']);
+ });
+});
diff --git a/apps/api/src/common/database/accepted-pricing-lock.ts b/apps/api/src/common/database/accepted-pricing-lock.ts
new file mode 100644
index 00000000..cc50c988
--- /dev/null
+++ b/apps/api/src/common/database/accepted-pricing-lock.ts
@@ -0,0 +1,35 @@
+import { and, eq } from 'drizzle-orm';
+import { reservations } from '@telivityhaip/database';
+
+type TransactionWork = (tx: any) => Promise;
+
+/**
+ * Serialize every accepted-price snapshot reader/writer for one reservation.
+ * The reservation row is the shared mutex for accepted-price readers/writers.
+ */
+export async function withAcceptedPricingLock(
+ db: any,
+ propertyId: string,
+ reservationId: string,
+ work: TransactionWork,
+ existingTx?: any,
+): Promise {
+ const execute = async (tx: any) => {
+ const [locked] = await tx
+ .select({ id: reservations.id })
+ .from(reservations)
+ .where(and(
+ eq(reservations.id, reservationId),
+ eq(reservations.propertyId, propertyId),
+ ))
+ .for('update');
+ if (!locked) {
+ throw new Error(
+ `Reservation ${reservationId} not found for accepted-pricing lock`,
+ );
+ }
+ return work(tx);
+ };
+
+ return existingTx ? execute(existingTx) : db.transaction(execute);
+}
diff --git a/apps/api/src/common/validation/is-money-string.validator.ts b/apps/api/src/common/validation/is-money-string.validator.ts
index 1bfa5f3d..1c59f652 100644
--- a/apps/api/src/common/validation/is-money-string.validator.ts
+++ b/apps/api/src/common/validation/is-money-string.validator.ts
@@ -1,69 +1,2 @@
-import {
- registerDecorator,
- type ValidationOptions,
- ValidatorConstraint,
- type ValidatorConstraintInterface,
-} from 'class-validator';
-import Decimal from 'decimal.js';
-
-export interface MoneyStringOptions {
- /** Allow exactly zero (default false → must be strictly positive). */
- allowZero?: boolean;
- /** Allow negative amounts (default false). Use for credit/adjustment fields. */
- allowNegative?: boolean;
- /** Inclusive upper bound, expressed as a decimal string. */
- maximum?: string;
-}
-
-@ValidatorConstraint({ name: 'isMoneyString', async: false })
-class MoneyStringConstraint implements ValidatorConstraintInterface {
- validate(value: unknown, args: any): boolean {
- if (typeof value !== 'string' || value.trim() === '') return false;
- let d: Decimal;
- try {
- d = new Decimal(value);
- } catch {
- return false;
- }
- if (!d.isFinite()) return false;
- const opts: MoneyStringOptions = args?.constraints?.[0] ?? {};
- if (!opts.allowNegative && d.isNegative()) return false;
- if (!opts.allowZero && !opts.allowNegative && d.isZero()) return false;
- if (opts.maximum != null && d.gt(new Decimal(opts.maximum))) return false;
- return true;
- }
-
- defaultMessage(args: any): string {
- const opts: MoneyStringOptions = args?.constraints?.[0] ?? {};
- const bound = opts.allowNegative
- ? 'a numeric decimal string'
- : opts.allowZero
- ? 'a non-negative numeric decimal string'
- : 'a positive numeric decimal string';
- return opts.maximum == null
- ? `${args?.property} must be ${bound}`
- : `${args?.property} must be ${bound} no greater than ${opts.maximum}`;
- }
-}
-
-/**
- * Validates a monetary value supplied as a decimal STRING (amounts are stored as
- * numeric strings to avoid float drift). Rejects NaN/Infinity and — by default —
- * zero and negatives, closing the "negative amount inverts the folio balance"
- * class of bug. Pass `{ allowNegative: true }` for legitimate credit/adjustment
- * fields, `{ allowZero: true }` where zero is meaningful (e.g. comp rooms).
- */
-export function IsMoneyString(
- opts: MoneyStringOptions = {},
- validationOptions?: ValidationOptions,
-) {
- return function (object: object, propertyName: string) {
- registerDecorator({
- target: object.constructor,
- propertyName,
- options: validationOptions,
- constraints: [opts],
- validator: MoneyStringConstraint,
- });
- };
-}
+/** Canonical definition lives in @telivityhaip/shared (used by @telivityhaip/booking-requests too). */
+export { IsMoneyString, type MoneyStringOptions } from '@telivityhaip/shared';
diff --git a/apps/api/src/database/database.module.ts b/apps/api/src/database/database.module.ts
index c6e4c64a..2be848f2 100644
--- a/apps/api/src/database/database.module.ts
+++ b/apps/api/src/database/database.module.ts
@@ -3,17 +3,41 @@ import { ConfigService } from '@nestjs/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '@telivityhaip/database';
-import { postgresOptionsFromEnv } from '@telivityhaip/database';
+import { DRIZZLE as DRIZZLE_TOKEN, postgresOptionsFromEnv } from '@telivityhaip/database';
+// `@telivityhaip/shared`, NOT `@telivityhaip/booking-requests`: this module is
+// foundational (every DB-touching provider transitively imports it for
+// DRIZZLE), so a static import of the full booking-requests bundle here would
+// force it into every test file's module graph — breaking any test that
+// partially mocks `@telivityhaip/database` with `vi.mock(...)` (booking-requests'
+// bundle imports that module too). The package's own optional Drizzle schema
+// merge below is loaded with a dynamic `import()` instead, gated by this same
+// flag, so it only loads when the feature is actually enabled.
+import { isBookingRequestsEnabled } from '@telivityhaip/shared';
-export const DRIZZLE = Symbol('DRIZZLE');
+/**
+ * Re-exported (via a re-export clause, NOT `import { DRIZZLE } from ...` +
+ * `export { DRIZZLE }`) for the ~160 existing call sites importing DRIZZLE
+ * from here. The two-step import-then-export form previously here compiled
+ * to a broken live-binding getter under Vite/Vitest's SSR module transform
+ * (`ReferenceError: DRIZZLE is not defined` at every call site, even when
+ * `@telivityhaip/database` was not mocked at all) — this single re-export
+ * statement sidesteps that transform bug entirely.
+ */
+export { DRIZZLE } from '@telivityhaip/database';
+
+async function loadSchema() {
+ if (!isBookingRequestsEnabled()) return schema;
+ const bookingRequestsSchema = await import('@telivityhaip/booking-requests/schema');
+ return { ...schema, ...bookingRequestsSchema };
+}
@Global()
@Module({
providers: [
{
- provide: DRIZZLE,
+ provide: DRIZZLE_TOKEN,
inject: [ConfigService],
- useFactory: (config: ConfigService) => {
+ useFactory: async (config: ConfigService) => {
const url = config.get(
'DATABASE_URL',
'postgresql://haip:haip@localhost:5432/haip',
@@ -25,10 +49,11 @@ export const DRIZZLE = Symbol('DRIZZLE');
DATABASE_SSL: config.get('DATABASE_SSL'),
}),
);
- return drizzle(client, { schema });
+ const mergedSchema = await loadSchema();
+ return drizzle(client, { schema: mergedSchema });
},
},
],
- exports: [DRIZZLE],
+ exports: [DRIZZLE_TOKEN],
})
export class DatabaseModule {}
diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts
index 9dd88fb5..e9b334d6 100644
--- a/apps/api/src/main.ts
+++ b/apps/api/src/main.ts
@@ -2,10 +2,10 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { json, raw } from 'express';
-import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { securityHeaders } from './common/http/security-headers';
import { assertSecureConfig } from './common/config/assert-secure-config';
+import { preloadBookingRequestsModules } from './booking-requests.bootstrap';
function corsOrigins(): boolean | string[] {
const raw = process.env['CORS_ORIGINS'];
@@ -20,6 +20,12 @@ function corsOrigins(): boolean | string[] {
async function bootstrap() {
assertSecureConfig();
+ await preloadBookingRequestsModules();
+
+ // AppModule calls bookingRequestsModules() while its imports array is
+ // evaluated — that requires preload above to have run first when the flag
+ // is on. Dynamic import keeps AppModule from loading before preload.
+ const { AppModule } = await import('./app.module.js');
const app = await NestFactory.create(AppModule);
diff --git a/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts b/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts
new file mode 100644
index 00000000..71a9f509
--- /dev/null
+++ b/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts
@@ -0,0 +1,975 @@
+import { describe, expect, it, vi } from 'vitest';
+import { reservations } from '@telivityhaip/database';
+import { AncillaryService } from './ancillary.service';
+import { WebhookService } from '../webhook/webhook.service';
+
+function stagedSelect(stages: any[][]) {
+ let index = 0;
+ const select: any = vi.fn((selection?: Record) => {
+ const reservationMutex = selection?.id === reservations.id;
+ const rows = reservationMutex ? [{ id: 'res-1' }] : stages[index++] ?? [];
+ const promise = Promise.resolve(rows);
+ const chain: any = {
+ from: vi.fn(() => chain),
+ innerJoin: vi.fn(() => chain),
+ where: vi.fn(() => chain),
+ for: vi.fn(async () => {
+ if (reservationMutex) select.reservationLockCount++;
+ return rows;
+ }),
+ limit: vi.fn(() => promise),
+ then: promise.then.bind(promise),
+ };
+ return chain;
+ });
+ select.reservationLockCount = 0;
+ return select;
+}
+
+function transactionalDb>(db: T): T {
+ db.execute = vi.fn(async () => undefined);
+ db.transaction = vi.fn(async (work: (tx: T) => Promise) => work(db));
+ return db;
+}
+
+function recordedWebhookService() {
+ const audits: Record[] = [];
+ const eventEmitter = { emit: vi.fn() };
+ const webhook = new WebhookService({
+ insert: vi.fn(() => ({
+ values: vi.fn(async (row: Record) => {
+ audits.push(row);
+ }),
+ })),
+ } as any, eventEmitter as any);
+ return { webhook, eventEmitter, audits };
+}
+
+function idempotentSnapshotPoster(hasExistingGroup = false) {
+ const ledgerGroups: Array<{ base: { id: string }; tax: { id: string } }> = [];
+ if (hasExistingGroup) {
+ ledgerGroups.push({
+ base: { id: 'charge-1' },
+ tax: { id: 'tax-1' },
+ });
+ }
+ const postChargeFromSnapshotWithOutcome = vi.fn(async () => {
+ const wasCreated = ledgerGroups.length === 0;
+ if (wasCreated) {
+ ledgerGroups.push({
+ base: { id: 'charge-1' },
+ tax: { id: 'tax-1' },
+ });
+ }
+ return { charge: ledgerGroups[0].base, wasCreated };
+ });
+ return {
+ ledgerGroups,
+ folio: {
+ postCharge: vi.fn(),
+ postChargeFromSnapshotWithOutcome,
+ postChargeFromSnapshot: vi.fn(async (...args: unknown[]) =>
+ (await postChargeFromSnapshotWithOutcome(...args)).charge),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ },
+ };
+}
+
+function acceptedOnceScenario() {
+ const reservation = {
+ id: 'res-1',
+ propertyId: 'prop-1',
+ guestId: 'guest-1',
+ arrivalDate: '2026-10-01',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR',
+ services: [{
+ serviceId: 'svc-1',
+ postingRule: 'once',
+ chargeType: 'parking',
+ lineItems: [{ date: '2026-10-01', amount: '15.00', taxAmount: '2.00' }],
+ }],
+ },
+ };
+ const rs = {
+ id: 'rs-1',
+ propertyId: 'prop-1',
+ reservationId: 'res-1',
+ serviceId: 'svc-1',
+ unitPrice: '99.00',
+ quantity: 1,
+ chargeType: 'parking',
+ currencyCode: 'EUR',
+ postingRule: 'once',
+ status: 'confirmed',
+ };
+ let status = 'confirmed';
+ const casReturning = vi.fn(async () => {
+ if (status !== 'confirmed') return [];
+ status = 'posted';
+ return [{ ...rs, status }];
+ });
+ const update = vi.fn(() => ({
+ set: vi.fn(() => ({
+ where: vi.fn(() => ({ returning: casReturning })),
+ })),
+ }));
+ let transactionQueue = Promise.resolve();
+ const createDb = () => {
+ const db: any = {
+ execute: vi.fn(async () => undefined),
+ select: vi.fn(),
+ update,
+ };
+ db.transaction = vi.fn(async (work: (tx: any) => Promise) => {
+ const previous = transactionQueue;
+ let release!: () => void;
+ transactionQueue = new Promise((resolve) => { release = resolve; });
+ await previous;
+ db.select = stagedSelect([
+ [reservation],
+ [{ id: 'folio-1' }],
+ [{ rs: { ...rs, status }, serviceName: 'Parking' }],
+ ]);
+ try {
+ return await work(db);
+ } finally {
+ release();
+ }
+ });
+ return db;
+ };
+ return { createDb, update, casReturning };
+}
+
+describe('AncillaryService accepted operational pricing', () => {
+ it('skips frozen once posting while allowing an active manual duplicate', async () => {
+ const reservation = {
+ id: 'res-1', propertyId: 'prop-1', guestId: 'guest-1', arrivalDate: '2026-10-01',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR',
+ services: [{
+ serviceId: 'svc-1', postingRule: 'once', chargeType: 'parking',
+ lineItems: [{ date: '2026-10-01', amount: '15.00', taxAmount: '2.00' }],
+ }],
+ },
+ };
+ const cancelled = {
+ id: 'rs-accepted', serviceId: 'svc-1', status: 'cancelled', postingRule: 'once',
+ chargeType: 'parking', unitPrice: '15.00', quantity: 1, currencyCode: 'EUR',
+ sourceChannel: 'booking_engine', createdAt: new Date('2026-08-24T10:00:00Z'),
+ };
+ const active = {
+ ...cancelled, id: 'rs-frontdesk', status: 'confirmed', sourceChannel: 'front_desk',
+ createdAt: new Date('2026-08-25T10:00:00Z'),
+ };
+ const db = transactionalDb({
+ select: stagedSelect([
+ [reservation], [{ id: 'folio-1' }],
+ [{ rs: cancelled, serviceName: 'Parking' }, { rs: active, serviceName: 'Parking' }], [],
+ ]),
+ update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn(() => ({
+ returning: vi.fn(async () => [{ ...active, status: 'posted' }]),
+ })) })) })),
+ });
+ const folio = {
+ postCharge: vi.fn().mockResolvedValue({ id: 'manual-charge', taxCharges: [] }),
+ postChargeFromSnapshotWithOutcome: vi.fn(),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(db as any, folio as any, { emit: vi.fn() } as any);
+
+ await expect(service.postOnceForReservation('res-1', 'prop-1'))
+ .resolves.toMatchObject({ count: 1 });
+ expect(folio.postChargeFromSnapshotWithOutcome).not.toHaveBeenCalled();
+ });
+
+ it('posts a manual once extra independently when the accepted duplicate is cancelled', async () => {
+ const reservation = {
+ id: 'res-1', propertyId: 'prop-1', guestId: 'guest-1', arrivalDate: '2026-10-01',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR', services: [{
+ serviceId: 'svc-1', postingRule: 'once', chargeType: 'parking',
+ lineItems: [{ date: '2026-10-01', amount: '15.00', taxAmount: '2.00' }],
+ }],
+ },
+ };
+ const accepted = {
+ id: 'rs-accepted', serviceId: 'svc-1', status: 'cancelled', postingRule: 'once',
+ chargeType: 'parking', unitPrice: '15.00', quantity: 1, currencyCode: 'EUR',
+ sourceChannel: 'booking_engine', createdAt: new Date('2026-08-24T10:00:00Z'),
+ };
+ const manual = {
+ ...accepted, id: 'rs-manual', status: 'confirmed', sourceChannel: 'front_desk',
+ unitPrice: '27.00', createdAt: new Date('2026-08-25T10:00:00Z'),
+ };
+ const db = transactionalDb({
+ select: stagedSelect([
+ [reservation], [{ id: 'folio-1' }],
+ [{ rs: accepted, serviceName: 'Parking' }, { rs: manual, serviceName: 'Parking' }],
+ [],
+ ]),
+ update: vi.fn(() => ({
+ set: vi.fn(() => ({
+ where: vi.fn(() => ({ returning: vi.fn(async () => [{ ...manual, status: 'posted' }]) })),
+ })),
+ })),
+ });
+ const folio = {
+ postCharge: vi.fn().mockResolvedValue({ id: 'manual-charge', taxCharges: [] }),
+ postChargeFromSnapshotWithOutcome: vi.fn(), emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(db as any, folio as any, { emit: vi.fn() } as any);
+
+ await expect(service.postOnceForReservation('res-1', 'prop-1'))
+ .resolves.toMatchObject({ count: 1 });
+ expect(folio.postCharge).toHaveBeenCalledWith(
+ 'folio-1', expect.objectContaining({ amount: '27.00' }), expect.anything(),
+ );
+ expect(folio.postChargeFromSnapshotWithOutcome).not.toHaveBeenCalled();
+ });
+
+ it('posts accepted and manual once rows independently at frozen and live amounts', async () => {
+ const reservation = {
+ id: 'res-1', propertyId: 'prop-1', guestId: 'guest-1', arrivalDate: '2026-10-01',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR', services: [{
+ serviceId: 'svc-1', postingRule: 'once', chargeType: 'parking',
+ lineItems: [{ date: '2026-10-01', amount: '15.00', taxAmount: '2.00' }],
+ }],
+ },
+ };
+ const accepted = {
+ id: 'rs-accepted', serviceId: 'svc-1', status: 'confirmed', postingRule: 'once',
+ chargeType: 'parking', unitPrice: '99.00', quantity: 1, currencyCode: 'EUR',
+ sourceChannel: 'booking_engine', createdAt: new Date('2026-08-24T10:00:00Z'),
+ };
+ const manual = {
+ ...accepted, id: 'rs-manual', sourceChannel: 'front_desk', unitPrice: '27.00',
+ createdAt: new Date('2026-08-25T10:00:00Z'),
+ };
+ const db = transactionalDb({
+ select: stagedSelect([
+ [reservation], [{ id: 'folio-1' }],
+ [{ rs: accepted, serviceName: 'Parking' }, { rs: manual, serviceName: 'Parking' }],
+ [],
+ ]),
+ update: vi.fn(() => ({ set: vi.fn(() => ({
+ where: vi.fn(() => ({ returning: vi.fn(async () => [{ status: 'posted' }]) })),
+ })) })),
+ });
+ const folio = {
+ postCharge: vi.fn().mockResolvedValue({ id: 'manual-charge', taxCharges: [] }),
+ postChargeFromSnapshotWithOutcome: vi.fn().mockResolvedValue({
+ charge: { id: 'accepted-charge' }, wasCreated: true,
+ }),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(db as any, folio as any, { emit: vi.fn() } as any);
+
+ await expect(service.postOnceForReservation('res-1', 'prop-1'))
+ .resolves.toMatchObject({ count: 2 });
+ expect(folio.postChargeFromSnapshotWithOutcome).toHaveBeenCalledWith(
+ 'folio-1', expect.objectContaining({ amount: '15.00' }), '2.00', undefined,
+ 'accepted-pricing:reservation-service:rs-accepted:once:2026-10-01', expect.anything(),
+ );
+ expect(folio.postCharge).toHaveBeenCalledWith(
+ 'folio-1', expect.objectContaining({ amount: '27.00' }), expect.anything(),
+ );
+ });
+
+ it('posts a manual per-night extra independently of a cancelled accepted duplicate', async () => {
+ const reservation = {
+ id: 'res-1', propertyId: 'prop-1', guestId: 'guest-1', status: 'checked_in',
+ acceptedPricingSnapshot: { currencyCode: 'EUR', services: [{
+ serviceId: 'svc-1', postingRule: 'per_night', chargeType: 'parking',
+ lineItems: [{ date: '2026-10-02', amount: '15.00', taxAmount: '2.00' }],
+ }] },
+ };
+ const accepted = {
+ id: 'rs-accepted', propertyId: 'prop-1', reservationId: 'res-1', serviceId: 'svc-1',
+ status: 'cancelled', postingRule: 'per_night', chargeType: 'parking',
+ unitPrice: '15.00', quantity: 1, currencyCode: 'EUR', sourceChannel: 'booking_engine',
+ createdAt: new Date('2026-08-24T10:00:00Z'),
+ };
+ const manual = {
+ ...accepted, id: 'rs-manual', status: 'confirmed', sourceChannel: 'front_desk',
+ unitPrice: '27.00', createdAt: new Date('2026-08-25T10:00:00Z'),
+ };
+ const candidates = [
+ { rs: accepted, serviceName: 'Parking', reservation },
+ { rs: manual, serviceName: 'Parking', reservation },
+ ];
+ const db = transactionalDb({
+ select: stagedSelect([candidates, candidates, candidates, [{ id: 'folio-1' }], []]),
+ });
+ const folio = {
+ postCharge: vi.fn().mockResolvedValue({ id: 'manual-charge', taxCharges: [] }),
+ postChargeFromSnapshotWithOutcome: vi.fn(), emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(db as any, folio as any, { emit: vi.fn() } as any);
+
+ const result = await service.postPerNightForProperty('prop-1', '2026-10-02');
+
+ expect(result.posted).toEqual([
+ { reservationServiceId: 'rs-manual', chargeId: 'manual-charge', amount: '27.00' },
+ ]);
+ expect(folio.postCharge).toHaveBeenCalledOnce();
+ expect(folio.postChargeFromSnapshotWithOutcome).not.toHaveBeenCalled();
+ });
+
+ it('posts accepted and manual per-night rows independently', async () => {
+ const reservation = {
+ id: 'res-1', propertyId: 'prop-1', guestId: 'guest-1', status: 'checked_in',
+ acceptedPricingSnapshot: { currencyCode: 'EUR', services: [{
+ serviceId: 'svc-1', postingRule: 'per_night', chargeType: 'parking',
+ lineItems: [{ date: '2026-10-02', amount: '15.00', taxAmount: '2.00' }],
+ }] },
+ };
+ const accepted = {
+ id: 'rs-accepted', propertyId: 'prop-1', reservationId: 'res-1', serviceId: 'svc-1',
+ status: 'confirmed', postingRule: 'per_night', chargeType: 'parking',
+ unitPrice: '99.00', quantity: 1, currencyCode: 'EUR', sourceChannel: 'booking_engine',
+ createdAt: new Date('2026-08-24T10:00:00Z'),
+ };
+ const manual = {
+ ...accepted, id: 'rs-manual', sourceChannel: 'front_desk', unitPrice: '27.00',
+ createdAt: new Date('2026-08-25T10:00:00Z'),
+ };
+ const candidates = [
+ { rs: accepted, serviceName: 'Parking', reservation },
+ { rs: manual, serviceName: 'Parking', reservation },
+ ];
+ const db = transactionalDb({
+ select: stagedSelect([
+ candidates, candidates, [{ id: 'folio-1' }],
+ candidates, [{ id: 'folio-1' }], [],
+ ]),
+ });
+ const folio = {
+ postCharge: vi.fn().mockResolvedValue({ id: 'manual-charge', taxCharges: [] }),
+ postChargeFromSnapshotWithOutcome: vi.fn().mockResolvedValue({
+ charge: { id: 'accepted-charge' }, wasCreated: true,
+ }), emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(db as any, folio as any, { emit: vi.fn() } as any);
+
+ const result = await service.postPerNightForProperty('prop-1', '2026-10-02');
+
+ expect(result.posted).toEqual(expect.arrayContaining([
+ { reservationServiceId: 'rs-accepted', chargeId: 'accepted-charge', amount: '15.00' },
+ { reservationServiceId: 'rs-manual', chargeId: 'manual-charge', amount: '27.00' },
+ ]));
+ expect(folio.postChargeFromSnapshotWithOutcome).toHaveBeenCalledOnce();
+ expect(folio.postCharge).toHaveBeenCalledOnce();
+ });
+
+ it('never resurrects a cancelled accepted once service', async () => {
+ const reservation = {
+ id: 'res-1', propertyId: 'prop-1', guestId: 'guest-1', arrivalDate: '2026-10-01',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR',
+ services: [{
+ serviceId: 'svc-1', postingRule: 'once', chargeType: 'parking',
+ lineItems: [{ date: '2026-10-01', amount: '15.00', taxAmount: '2.00' }],
+ }],
+ },
+ };
+ const rs = {
+ id: 'rs-1', serviceId: 'svc-1', status: 'cancelled', postingRule: 'once',
+ chargeType: 'parking', unitPrice: '15.00', quantity: 1, currencyCode: 'EUR',
+ };
+ const db = transactionalDb({
+ select: stagedSelect([[reservation], [{ id: 'folio-1' }], [{ rs, serviceName: 'Parking' }]]),
+ update: vi.fn(),
+ });
+ const folio = {
+ postCharge: vi.fn(), postChargeFromSnapshotWithOutcome: vi.fn(),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(db as any, folio as any, { emit: vi.fn() } as any);
+
+ await expect(service.postOnceForReservation('res-1', 'prop-1'))
+ .resolves.toEqual({ posted: [], count: 0 });
+ expect(folio.postChargeFromSnapshotWithOutcome).not.toHaveBeenCalled();
+ expect(db.update).not.toHaveBeenCalled();
+ });
+
+ it('never resurrects a cancelled accepted per-night service after the candidate read', async () => {
+ const reservation = {
+ id: 'res-1', propertyId: 'prop-1', guestId: 'guest-1', status: 'checked_in',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR',
+ services: [{
+ serviceId: 'svc-1', postingRule: 'per_night', chargeType: 'parking',
+ lineItems: [{ date: '2026-10-02', amount: '15.00', taxAmount: '2.00' }],
+ }],
+ },
+ };
+ const candidate = {
+ id: 'rs-1', serviceId: 'svc-1', status: 'confirmed', postingRule: 'per_night',
+ chargeType: 'parking', unitPrice: '15.00', quantity: 1, currencyCode: 'EUR',
+ };
+ const cancelled = { ...candidate, status: 'cancelled' };
+ const db = transactionalDb({
+ select: stagedSelect([
+ [{ rs: candidate, serviceName: 'Parking', reservation }],
+ [{ rs: cancelled, serviceName: 'Parking', reservation }],
+ ]),
+ });
+ const folio = {
+ postCharge: vi.fn(), postChargeFromSnapshotWithOutcome: vi.fn(),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(db as any, folio as any, { emit: vi.fn() } as any);
+
+ const result = await service.postPerNightForProperty('prop-1', '2026-10-02');
+
+ expect(result.count).toBe(0);
+ expect(folio.postChargeFromSnapshotWithOutcome).not.toHaveBeenCalled();
+ });
+
+ it('re-reads the accepted service under the pricing lock before claiming a nightly group', async () => {
+ const staleReservation = {
+ id: 'res-1', propertyId: 'prop-1', guestId: 'guest-1', status: 'checked_in',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR',
+ services: [{
+ serviceId: 'svc-1', postingRule: 'per_night', chargeType: 'parking',
+ lineItems: [{ date: '2026-10-02', amount: '15.00', taxAmount: '2.00' }],
+ }],
+ },
+ };
+ const lockedReservation = {
+ ...staleReservation,
+ acceptedPricingSnapshot: { currencyCode: 'EUR', services: [] },
+ };
+ const rs = {
+ id: 'rs-1', propertyId: 'prop-1', reservationId: 'res-1', serviceId: 'svc-1',
+ status: 'confirmed', postingRule: 'per_night', chargeType: 'parking',
+ unitPrice: '15.00', quantity: 1, currencyCode: 'EUR',
+ };
+ const db: any = {
+ execute: vi.fn(async () => undefined),
+ select: stagedSelect([
+ [{ rs, serviceName: 'Parking', reservation: staleReservation }],
+ [{ rs, serviceName: 'Parking', reservation: lockedReservation }],
+ ]),
+ };
+ db.transaction = vi.fn(async (work: (tx: any) => Promise) => work(db));
+ const folio = {
+ postCharge: vi.fn(),
+ postChargeFromSnapshotWithOutcome: vi.fn(),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(db, folio as any, { emit: vi.fn() } as any);
+
+ const result = await service.postPerNightForProperty('prop-1', '2026-10-02');
+
+ expect(result.count).toBe(0);
+ expect(folio.postChargeFromSnapshotWithOutcome).not.toHaveBeenCalled();
+ expect(db.select.reservationLockCount).toBe(1);
+ });
+
+ it('posts a once service from the amended snapshot when the live row is still per-night', async () => {
+ const reservation = {
+ id: 'res-1',
+ propertyId: 'prop-1',
+ guestId: 'guest-1',
+ arrivalDate: '2026-10-01',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR',
+ services: [{
+ serviceId: 'svc-1',
+ postingRule: 'once',
+ chargeType: 'fee',
+ lineItems: [{ date: '2026-10-01', amount: '21.00', taxAmount: '3.00' }],
+ }],
+ },
+ };
+ const rs = {
+ id: 'rs-1', serviceId: 'svc-1', unitPrice: '99.00', quantity: 1,
+ chargeType: 'parking', currencyCode: 'EUR', postingRule: 'per_night', status: 'confirmed',
+ };
+ const db = transactionalDb({
+ select: stagedSelect([[reservation], [{ id: 'folio-1' }], [{ rs, serviceName: 'Transfer' }]]),
+ update: vi.fn(() => ({
+ set: vi.fn(() => ({
+ where: vi.fn(() => ({ returning: vi.fn(async () => [{ ...rs, status: 'posted' }]) })),
+ })),
+ })),
+ });
+ const folio = {
+ postCharge: vi.fn(),
+ postChargeFromSnapshotWithOutcome: vi.fn().mockResolvedValue({
+ charge: { id: 'charge-1' }, wasCreated: true,
+ }),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(db as any, folio as any, { emit: vi.fn() } as any);
+
+ await service.postOnceForReservation('res-1', 'prop-1');
+
+ expect(folio.postChargeFromSnapshotWithOutcome).toHaveBeenCalledWith(
+ 'folio-1',
+ expect.objectContaining({ type: 'fee', amount: '21.00' }),
+ '3.00',
+ undefined,
+ 'accepted-pricing:reservation-service:rs-1:once:2026-10-01',
+ expect.anything(),
+ );
+ });
+
+ it('posts a re-dated once revision even when the operational row was already posted', async () => {
+ const reservation = {
+ id: 'res-1', propertyId: 'prop-1', guestId: 'guest-1', arrivalDate: '2026-10-02',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR',
+ services: [{
+ serviceId: 'svc-1', postingRule: 'once', chargeType: 'parking',
+ lineItems: [{ date: '2026-10-02', amount: '15.00', taxAmount: '2.00' }],
+ }],
+ },
+ };
+ const rs = {
+ id: 'rs-1', serviceId: 'svc-1', status: 'posted', postingRule: 'once',
+ chargeType: 'parking', unitPrice: '15.00', quantity: 1, currencyCode: 'EUR',
+ };
+ const db = transactionalDb({
+ select: stagedSelect([[reservation], [{ id: 'folio-1' }], [{ rs, serviceName: 'Parking' }]]),
+ update: vi.fn(),
+ });
+ const folio = {
+ postCharge: vi.fn(),
+ postChargeFromSnapshotWithOutcome: vi.fn().mockResolvedValue({
+ charge: { id: 'charge-new-date' }, wasCreated: true,
+ }),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const webhook = { emit: vi.fn() };
+ const service = new AncillaryService(db as any, folio as any, webhook as any);
+
+ const result = await service.postOnceForReservation('res-1', 'prop-1');
+
+ expect(result).toEqual({ posted: [rs], count: 1 });
+ expect(db.update).not.toHaveBeenCalled();
+ expect(folio.postChargeFromSnapshotWithOutcome).toHaveBeenCalledWith(
+ 'folio-1', expect.anything(), '2.00', undefined,
+ 'accepted-pricing:reservation-service:rs-1:once:2026-10-02', expect.anything(),
+ );
+ expect(webhook.emit).toHaveBeenCalledWith(
+ 'reservation.service_posted', 'reservation_service', 'rs-1',
+ expect.objectContaining({ amount: '15.00', postingRule: 'once' }), 'prop-1',
+ );
+ });
+
+ it('posts future per-night lines from the amended snapshot after a once service was marked posted', async () => {
+ const reservation = {
+ id: 'res-1', propertyId: 'prop-1', guestId: 'guest-1', status: 'checked_in',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR',
+ services: [{
+ serviceId: 'svc-1',
+ postingRule: 'per_night',
+ chargeType: 'spa',
+ lineItems: [{ date: '2026-10-03', amount: '30.00', taxAmount: '4.00' }],
+ }],
+ },
+ };
+ const rs = {
+ id: 'rs-1', serviceId: 'svc-1', unitPrice: '15.00', quantity: 1,
+ chargeType: 'parking', currencyCode: 'EUR', postingRule: 'once', status: 'posted',
+ };
+ const db = transactionalDb({
+ select: stagedSelect([
+ [{ rs, serviceName: 'Spa', reservation }],
+ [{ rs, serviceName: 'Spa', reservation }],
+ [{ id: 'folio-1' }],
+ ]),
+ });
+ const folio = {
+ postCharge: vi.fn(),
+ postChargeFromSnapshotWithOutcome: vi.fn().mockResolvedValue({
+ charge: { id: 'charge-1' }, wasCreated: true,
+ }),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(db as any, folio as any, { emit: vi.fn() } as any);
+
+ const result = await service.postPerNightForProperty('prop-1', '2026-10-03');
+
+ expect(folio.postChargeFromSnapshotWithOutcome).toHaveBeenCalledWith(
+ 'folio-1',
+ expect.objectContaining({ type: 'spa', amount: '30.00' }),
+ '4.00',
+ undefined,
+ 'accepted-pricing:reservation-service:rs-1:night:2026-10-03',
+ expect.anything(),
+ );
+ expect(result.count).toBe(1);
+ });
+
+ it('uses the amended snapshot dates instead of the stale live service range', async () => {
+ const reservation = {
+ id: 'res-1', propertyId: 'prop-1', guestId: 'guest-1', status: 'checked_in',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR',
+ services: [{
+ serviceId: 'svc-1',
+ postingRule: 'per_night',
+ chargeType: 'spa',
+ lineItems: [{ date: '2026-10-04', amount: '30.00', taxAmount: '4.00' }],
+ }],
+ },
+ };
+ const rs = {
+ id: 'rs-1', serviceId: 'svc-1', unitPrice: '15.00', quantity: 1,
+ chargeType: 'parking', currencyCode: 'EUR', postingRule: 'per_night',
+ status: 'confirmed', startDate: '2026-10-01', endDate: '2026-10-02',
+ };
+ const db = transactionalDb({
+ select: stagedSelect([
+ [{ rs, serviceName: 'Spa', reservation }],
+ [{ rs, serviceName: 'Spa', reservation }],
+ [{ id: 'folio-1' }],
+ ]),
+ });
+ const folio = {
+ postCharge: vi.fn(),
+ postChargeFromSnapshotWithOutcome: vi.fn().mockResolvedValue({
+ charge: { id: 'charge-1' }, wasCreated: true,
+ }),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(db as any, folio as any, { emit: vi.fn() } as any);
+
+ const result = await service.postPerNightForProperty('prop-1', '2026-10-04');
+
+ expect(folio.postChargeFromSnapshotWithOutcome).toHaveBeenCalledWith(
+ 'folio-1',
+ expect.objectContaining({ type: 'spa', amount: '30.00' }),
+ '4.00',
+ undefined,
+ 'accepted-pricing:reservation-service:rs-1:night:2026-10-04',
+ expect.anything(),
+ );
+ expect(result.count).toBe(1);
+ });
+
+ it('does not post a live service row removed from the amended snapshot', async () => {
+ const reservation = {
+ id: 'res-1', propertyId: 'prop-1', guestId: 'guest-1', arrivalDate: '2026-10-01',
+ acceptedPricingSnapshot: { currencyCode: 'EUR', services: [] },
+ };
+ const rs = {
+ id: 'rs-1', serviceId: 'svc-1', unitPrice: '99.00', quantity: 1,
+ chargeType: 'parking', currencyCode: 'EUR', postingRule: 'once', status: 'confirmed',
+ sourceChannel: 'booking_engine',
+ };
+ const db = transactionalDb({
+ select: stagedSelect([[reservation], [{ id: 'folio-1' }], [{ rs, serviceName: 'Parking' }]]),
+ update: vi.fn(),
+ });
+ const folio = {
+ postCharge: vi.fn(),
+ postChargeFromSnapshotWithOutcome: vi.fn(),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(db as any, folio as any, { emit: vi.fn() } as any);
+
+ const result = await service.postOnceForReservation('res-1', 'prop-1');
+
+ expect(result.count).toBe(0);
+ expect(folio.postCharge).not.toHaveBeenCalled();
+ expect(folio.postChargeFromSnapshotWithOutcome).not.toHaveBeenCalled();
+ });
+
+ it('recovers a confirmed once service when its accepted ledger group already exists', async () => {
+ const { createDb, update } = acceptedOnceScenario();
+ const { ledgerGroups, folio } = idempotentSnapshotPoster(true);
+ const { webhook, eventEmitter, audits } = recordedWebhookService();
+ const service = new AncillaryService(createDb() as any, folio as any, webhook);
+
+ const result = await service.postOnceForReservation('res-1', 'prop-1');
+
+ expect(ledgerGroups).toHaveLength(1);
+ expect(update).toHaveBeenCalledOnce();
+ expect(eventEmitter.emit).toHaveBeenCalledOnce();
+ expect(eventEmitter.emit).toHaveBeenCalledWith(
+ 'reservation.service_posted',
+ expect.objectContaining({ entityId: 'rs-1', propertyId: 'prop-1' }),
+ );
+ expect(audits).toHaveLength(1);
+ expect(result.count).toBe(1);
+ });
+
+ it('lets only one concurrent once-service replay win the state CAS and emit', async () => {
+ const { createDb, update, casReturning } = acceptedOnceScenario();
+ const { ledgerGroups, folio } = idempotentSnapshotPoster(true);
+ const { webhook, eventEmitter, audits } = recordedWebhookService();
+ const first = new AncillaryService(createDb() as any, folio as any, webhook);
+ const second = new AncillaryService(createDb() as any, folio as any, webhook);
+
+ const results = await Promise.all([
+ first.postOnceForReservation('res-1', 'prop-1'),
+ second.postOnceForReservation('res-1', 'prop-1'),
+ ]);
+
+ expect(ledgerGroups).toHaveLength(1);
+ expect(update).toHaveBeenCalledOnce();
+ expect(casReturning).toHaveBeenCalledOnce();
+ expect(eventEmitter.emit).toHaveBeenCalledOnce();
+ expect(audits).toHaveLength(1);
+ expect(results.map((result) => result.count).sort()).toEqual([0, 1]);
+ });
+
+ it('lets only the concurrent per-night ledger winner emit', async () => {
+ const reservation = {
+ id: 'res-1',
+ propertyId: 'prop-1',
+ guestId: 'guest-1',
+ status: 'checked_in',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR',
+ services: [{
+ serviceId: 'svc-1',
+ postingRule: 'per_night',
+ chargeType: 'parking',
+ lineItems: [{ date: '2026-10-02', amount: '15.00', taxAmount: '2.00' }],
+ }],
+ },
+ };
+ const rs = {
+ id: 'rs-1',
+ propertyId: 'prop-1',
+ reservationId: 'res-1',
+ serviceId: 'svc-1',
+ unitPrice: '99.00',
+ quantity: 1,
+ chargeType: 'parking',
+ currencyCode: 'EUR',
+ postingRule: 'per_night',
+ status: 'confirmed',
+ };
+ const createDb = () => transactionalDb({
+ select: stagedSelect([
+ [{ rs, serviceName: 'Parking', reservation }],
+ [{ rs, serviceName: 'Parking', reservation }],
+ [{ id: 'folio-1' }],
+ ]),
+ });
+ const { ledgerGroups, folio } = idempotentSnapshotPoster();
+ const { webhook, eventEmitter, audits } = recordedWebhookService();
+ const first = new AncillaryService(createDb() as any, folio as any, webhook);
+ const second = new AncillaryService(createDb() as any, folio as any, webhook);
+
+ const results = await Promise.all([
+ first.postPerNightForProperty('prop-1', '2026-10-02'),
+ second.postPerNightForProperty('prop-1', '2026-10-02'),
+ ]);
+
+ expect(ledgerGroups).toHaveLength(1);
+ expect(eventEmitter.emit).toHaveBeenCalledOnce();
+ expect(eventEmitter.emit).toHaveBeenCalledWith(
+ 'reservation.service_posted',
+ expect.objectContaining({ entityId: 'rs-1', propertyId: 'prop-1' }),
+ );
+ expect(audits).toHaveLength(1);
+ expect(results.flatMap((result) => result.posted)).toHaveLength(1);
+ expect(results.flatMap((result) => result.skipped)).toEqual(['rs-1']);
+ expect(results.flatMap((result) => result.errors)).toEqual([]);
+ });
+
+ it('uses a stable source key when concurrent check-in attempts post a once service', async () => {
+ const reservation = {
+ id: 'res-1',
+ propertyId: 'prop-1',
+ guestId: 'guest-1',
+ arrivalDate: '2026-09-30',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR',
+ services: [{
+ serviceId: 'svc-1',
+ postingRule: 'once',
+ chargeType: 'parking',
+ lineItems: [{ date: '2026-10-01', amount: '15.00', taxAmount: '2.00' }],
+ }],
+ },
+ };
+ const rs = {
+ id: 'rs-1',
+ propertyId: 'prop-1',
+ reservationId: 'res-1',
+ serviceId: 'svc-1',
+ unitPrice: '99.00',
+ quantity: 1,
+ chargeType: 'parking',
+ currencyCode: 'EUR',
+ postingRule: 'once',
+ status: 'confirmed',
+ };
+ const db = transactionalDb({
+ select: stagedSelect([
+ [reservation],
+ [{ id: 'folio-1' }],
+ [{ rs, serviceName: 'Parking' }],
+ [],
+ ]),
+ update: vi.fn(() => ({
+ set: vi.fn(() => ({
+ where: vi.fn(() => ({
+ returning: vi.fn(async () => [{ ...rs, status: 'posted' }]),
+ })),
+ })),
+ })),
+ });
+ const folio = {
+ postCharge: vi.fn(),
+ postChargeFromSnapshotWithOutcome: vi.fn().mockResolvedValue({
+ charge: { id: 'charge-1' },
+ wasCreated: true,
+ }),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const service = new AncillaryService(
+ db as any,
+ folio as any,
+ { emit: vi.fn() } as any,
+ );
+
+ await service.postOnceForReservation('res-1', 'prop-1');
+
+ expect(folio.postChargeFromSnapshotWithOutcome).toHaveBeenCalledWith(
+ 'folio-1',
+ expect.objectContaining({
+ amount: '15.00',
+ currencyCode: 'EUR',
+ serviceDate: '2026-10-01T00:00:00.000Z',
+ }),
+ '2.00',
+ undefined,
+ 'accepted-pricing:reservation-service:rs-1:once:2026-10-01',
+ expect.anything(),
+ );
+ });
+
+ it('posts the frozen per-night service and tax instead of live catalog pricing', async () => {
+ const reservation = {
+ id: 'res-1',
+ propertyId: 'prop-1',
+ guestId: 'guest-1',
+ status: 'checked_in',
+ acceptedPricingSnapshot: {
+ currencyCode: 'EUR',
+ services: [{
+ serviceId: 'svc-1',
+ postingRule: 'per_night',
+ chargeType: 'parking',
+ lineItems: [
+ { date: '2026-10-01', amount: '15.00', taxAmount: '2.00' },
+ { date: '2026-10-02', amount: '15.00', taxAmount: '2.00' },
+ ],
+ }],
+ },
+ };
+ const current = {
+ rs: {
+ id: 'rs-1',
+ serviceId: 'svc-1',
+ unitPrice: '99.00',
+ quantity: 1,
+ chargeType: 'parking',
+ currencyCode: 'EUR',
+ postingRule: 'per_night',
+ sourceChannel: 'booking_engine',
+ status: 'confirmed',
+ },
+ serviceName: 'Parking',
+ reservation,
+ };
+ const db = transactionalDb({
+ select: stagedSelect([
+ [current],
+ [current],
+ [{ id: 'folio-1' }],
+ ]),
+ });
+ const folio = {
+ postCharge: vi.fn(),
+ postChargeFromSnapshotWithOutcome: vi.fn().mockResolvedValue({
+ charge: { id: 'charge-1' },
+ wasCreated: true,
+ }),
+ emitSnapshotChargeWebhooks: vi.fn(),
+ };
+ const webhook = { emit: vi.fn() };
+ const service = new AncillaryService(db as any, folio as any, webhook as any);
+
+ const result = await service.postPerNightForProperty('prop-1', '2026-10-02');
+
+ expect(folio.postChargeFromSnapshotWithOutcome).toHaveBeenCalledWith(
+ 'folio-1',
+ expect.objectContaining({ amount: '15.00', currencyCode: 'EUR' }),
+ '2.00',
+ undefined,
+ 'accepted-pricing:reservation-service:rs-1:night:2026-10-02',
+ expect.anything(),
+ );
+ expect(folio.postCharge).not.toHaveBeenCalled();
+ expect(result.posted).toEqual([
+ { reservationServiceId: 'rs-1', chargeId: 'charge-1', amount: '15.00' },
+ ]);
+ });
+
+ it('never attaches an unquoted package component at a live catalog price', async () => {
+ const inserted: Record[] = [];
+ const db = {
+ select: stagedSelect([
+ [{ id: 'res-1', propertyId: 'prop-1', ratePlanId: 'rp-1' }],
+ [{
+ serviceId: 'svc-package',
+ quantity: 1,
+ amountOverride: null,
+ includedInRate: false,
+ }],
+ [],
+ [{
+ id: 'svc-package',
+ propertyId: 'prop-1',
+ name: 'Package transfer',
+ price: '125.00',
+ currencyCode: 'EUR',
+ postingRule: 'once',
+ chargeType: 'fee',
+ }],
+ ]),
+ insert: vi.fn(() => ({
+ values: vi.fn((value: Record) => {
+ inserted.push(value);
+ return {
+ returning: vi.fn(async () => [{ id: 'rs-package', ...value }]),
+ };
+ }),
+ })),
+ };
+ const service = new AncillaryService(
+ db as any,
+ {} as any,
+ { emit: vi.fn() } as any,
+ );
+
+ await service.ensurePackageComponents(
+ 'res-1',
+ 'prop-1',
+ db,
+ { freezeUnquotedAtZero: true, currencyCode: 'EUR' },
+ );
+
+ expect(inserted[0]).toMatchObject({
+ serviceId: 'svc-package',
+ unitPrice: '0.00',
+ currencyCode: 'EUR',
+ sourceChannel: 'package',
+ });
+ });
+});
diff --git a/apps/api/src/modules/ancillary/ancillary.controller.ts b/apps/api/src/modules/ancillary/ancillary.controller.ts
index 39f0f098..a4894745 100644
--- a/apps/api/src/modules/ancillary/ancillary.controller.ts
+++ b/apps/api/src/modules/ancillary/ancillary.controller.ts
@@ -130,11 +130,11 @@ export class AncillaryController {
@ApiOperation({ summary: 'Cancel an attached reservation service' })
@ApiQuery({ name: 'propertyId', type: String, required: true })
cancelReservationService(
- @Param('reservationId', ParseUUIDPipe) _reservationId: string,
+ @Param('reservationId', ParseUUIDPipe) reservationId: string,
@Param('id', ParseUUIDPipe) id: string,
@Query('propertyId', ParseUUIDPipe) propertyId: string,
) {
- return this.ancillaryService.cancelReservationService(id, propertyId);
+ return this.ancillaryService.cancelReservationService(id, propertyId, reservationId);
}
@Post('reservations/:reservationId/post-once')
diff --git a/apps/api/src/modules/ancillary/ancillary.service.spec.ts b/apps/api/src/modules/ancillary/ancillary.service.spec.ts
index 7f19720f..43020ba1 100644
--- a/apps/api/src/modules/ancillary/ancillary.service.spec.ts
+++ b/apps/api/src/modules/ancillary/ancillary.service.spec.ts
@@ -1,5 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, BadRequestException } from '@nestjs/common';
+import { reservations } from '@telivityhaip/database';
import { AncillaryService } from './ancillary.service';
import { FolioService } from '../folio/folio.service';
import { WebhookService } from '../webhook/webhook.service';
@@ -109,7 +110,7 @@ describe('AncillaryService', () => {
describe('findServiceById (multi-tenancy)', () => {
it('throws NotFound when scoped propertyId does not match', async () => {
- const db = {
+ const db: any = {
select: vi.fn().mockImplementation(chainResolving([])),
insert: vi.fn(),
update: vi.fn(),
@@ -178,7 +179,15 @@ describe('AncillaryService', () => {
'reservation.service_attached',
'reservation_service',
mockRs.id,
- expect.any(Object),
+ {
+ reservationId: 'res-001',
+ serviceId: 'svc-001',
+ serviceName: 'Breakfast Buffet',
+ sourceChannel: 'front_desk',
+ quantity: 1,
+ unitPrice: '25.00',
+ postingRule: 'once',
+ },
'prop-001',
);
});
@@ -237,15 +246,30 @@ describe('AncillaryService', () => {
describe('cancelReservationService', () => {
it('sets status to cancelled', async () => {
const cancelled = { ...mockRs, status: 'cancelled' };
- const db = {
- select: vi.fn().mockImplementation(chainResolving([mockRs])),
+ const lockOrder: string[] = [];
+ const select = vi.fn((selection?: Record) => {
+ const reservationMutex = selection?.id === reservations.id;
+ const chain: any = {
+ from: vi.fn(() => chain),
+ where: vi.fn(() => chain),
+ for: vi.fn(async () => {
+ lockOrder.push(reservationMutex ? 'pricing-lock' : 'service');
+ return [reservationMutex ? mockReservation : mockRs];
+ }),
+ };
+ return chain;
+ });
+ const db: any = {
+ transaction: vi.fn(async (work: (tx: any) => Promise) => work(db)),
+ select,
insert: vi.fn(),
update: vi.fn().mockReturnValue(mutateResolving([cancelled])()),
delete: vi.fn(),
};
const svc = await buildService(db);
- const result = await svc.cancelReservationService('rs-001', 'prop-001');
+ const result = await svc.cancelReservationService('rs-001', 'prop-001', 'res-001');
expect(result.status).toBe('cancelled');
+ expect(lockOrder).toEqual(['pricing-lock', 'service']);
expect(mockWebhookService.emit).toHaveBeenCalledWith(
'reservation.service_cancelled',
'reservation_service',
diff --git a/apps/api/src/modules/ancillary/ancillary.service.ts b/apps/api/src/modules/ancillary/ancillary.service.ts
index e6a6275f..1470dd89 100644
--- a/apps/api/src/modules/ancillary/ancillary.service.ts
+++ b/apps/api/src/modules/ancillary/ancillary.service.ts
@@ -3,6 +3,7 @@ import {
Inject,
NotFoundException,
BadRequestException,
+ ConflictException,
} from '@nestjs/common';
import { eq, and, sql, inArray, like } from 'drizzle-orm';
import Decimal from 'decimal.js';
@@ -16,6 +17,8 @@ import {
ratePlans,
} from '@telivityhaip/database';
import { DRIZZLE } from '../../database/database.module';
+import { matchAcceptedReservationServiceRows } from '../../common/accepted-pricing/accepted-reservation-service';
+import { withAcceptedPricingLock } from '../../common/database/accepted-pricing-lock';
import { FolioService } from '../folio/folio.service';
import { WebhookService } from '../webhook/webhook.service';
import { CreateServiceDto } from './dto/create-service.dto';
@@ -23,6 +26,13 @@ import { UpdateServiceDto } from './dto/update-service.dto';
import { ListServicesDto } from './dto/list-services.dto';
import { CreateRatePlanComponentDto } from './dto/create-rate-plan-component.dto';
import { AttachReservationServiceDto } from './dto/attach-reservation-service.dto';
+import { reservationServiceAttachedPayload } from './reservation-service-event';
+
+export interface ReservationServicePricingOverride {
+ currencyCode: string;
+ postingRule: string;
+ chargeType: string;
+}
const IN_HOUSE_STATUSES = ['checked_in', 'stayover', 'due_out'] as const;
@@ -66,8 +76,9 @@ export class AncillaryService {
return row;
}
- async findServiceById(id: string, propertyId: string) {
- const [row] = await this.db
+ async findServiceById(id: string, propertyId: string, tx?: any) {
+ const db = tx ?? this.db;
+ const [row] = await db
.select()
.from(services)
.where(and(eq(services.id, id), eq(services.propertyId, propertyId)));
@@ -197,8 +208,9 @@ export class AncillaryService {
// --- Reservation services ---
- private async findReservation(reservationId: string, propertyId: string) {
- const [reservation] = await this.db
+ private async findReservation(reservationId: string, propertyId: string, tx?: any) {
+ const db = tx ?? this.db;
+ const [reservation] = await db
.select()
.from(reservations)
.where(
@@ -210,8 +222,9 @@ export class AncillaryService {
return reservation;
}
- private async findOpenGuestFolio(reservationId: string, propertyId: string) {
- const [folio] = await this.db
+ private async findOpenGuestFolio(reservationId: string, propertyId: string, tx?: any) {
+ const db = tx ?? this.db;
+ const [folio] = await db
.select()
.from(folios)
.where(
@@ -234,7 +247,9 @@ export class AncillaryService {
propertyId: string,
reservationServiceId: string,
businessDate?: string,
+ tx?: any,
): Promise {
+ const db = tx ?? this.db;
const conditions: any[] = [
eq(charges.folioId, folioId),
eq(charges.propertyId, propertyId),
@@ -244,7 +259,7 @@ export class AncillaryService {
if (businessDate) {
conditions.push(sql`${charges.serviceDate}::date = ${businessDate}`);
}
- const [existing] = await this.db
+ const [existing] = await db
.select({ id: charges.id })
.from(charges)
.where(and(...conditions))
@@ -252,9 +267,15 @@ export class AncillaryService {
return !!existing;
}
- async attachToReservation(reservationId: string, dto: AttachReservationServiceDto) {
- const reservation = await this.findReservation(reservationId, dto.propertyId);
- const service = await this.findServiceById(dto.serviceId, dto.propertyId);
+ async attachToReservation(
+ reservationId: string,
+ dto: AttachReservationServiceDto,
+ tx?: any,
+ pricingOverride?: ReservationServicePricingOverride,
+ ) {
+ const db = tx ?? this.db;
+ const reservation = await this.findReservation(reservationId, dto.propertyId, db);
+ const service = await this.findServiceById(dto.serviceId, dto.propertyId, db);
if (!service.isActive) {
throw new BadRequestException('Service is not active');
@@ -263,7 +284,7 @@ export class AncillaryService {
const quantity = dto.quantity ?? 1;
const unitPrice = dto.unitPrice ?? service.price;
- const [row] = await this.db
+ const [row] = await db
.insert(reservationServices)
.values({
propertyId: dto.propertyId,
@@ -271,33 +292,28 @@ export class AncillaryService {
serviceId: service.id,
quantity,
unitPrice,
- currencyCode: service.currencyCode,
+ currencyCode: pricingOverride?.currencyCode ?? service.currencyCode,
startDate: dto.startDate,
endDate: dto.endDate,
status: 'confirmed',
sourceChannel: dto.sourceChannel ?? 'front_desk',
- postingRule: service.postingRule,
- chargeType: service.chargeType,
+ postingRule: pricingOverride?.postingRule ?? service.postingRule,
+ chargeType: pricingOverride?.chargeType ?? service.chargeType,
notes: dto.notes,
})
.returning();
- await this.webhookService.emit(
- 'reservation.service_attached',
- 'reservation_service',
- row.id,
- {
- reservationId,
- serviceId: service.id,
- serviceName: service.name,
- quantity,
- unitPrice,
- postingRule: row.postingRule,
- },
- dto.propertyId,
- );
+ if (!tx) {
+ await this.webhookService.emit(
+ 'reservation.service_attached',
+ 'reservation_service',
+ row.id,
+ reservationServiceAttachedPayload(row, service.name),
+ dto.propertyId,
+ );
+ }
- return row;
+ return { ...row, serviceName: service.name };
}
async listForReservation(propertyId: string, reservationId: string) {
@@ -314,30 +330,51 @@ export class AncillaryService {
.orderBy(reservationServices.createdAt);
}
- async cancelReservationService(id: string, propertyId: string) {
- const [row] = await this.db
- .select()
- .from(reservationServices)
- .where(
- and(eq(reservationServices.id, id), eq(reservationServices.propertyId, propertyId)),
- );
- if (!row) {
- throw new NotFoundException(`Reservation service ${id} not found`);
- }
- if (row.status === 'cancelled') {
- throw new BadRequestException('Reservation service is already cancelled');
- }
- if (row.status === 'posted') {
- throw new BadRequestException('Cannot cancel a posted reservation service');
- }
+ async cancelReservationService(id: string, propertyId: string, reservationId: string) {
+ const updated = await withAcceptedPricingLock(
+ this.db,
+ propertyId,
+ reservationId,
+ async (tx) => {
+ const query = tx
+ .select()
+ .from(reservationServices)
+ .where(and(
+ eq(reservationServices.id, id),
+ eq(reservationServices.propertyId, propertyId),
+ eq(reservationServices.reservationId, reservationId),
+ ));
+ const [row] = typeof query.for === 'function'
+ ? await query.for('update')
+ : await query;
+ if (!row) {
+ throw new NotFoundException(`Reservation service ${id} not found`);
+ }
+ if (row.status === 'cancelled') {
+ throw new BadRequestException('Reservation service is already cancelled');
+ }
+ if (row.status === 'posted') {
+ throw new BadRequestException('Cannot cancel a posted reservation service');
+ }
- const [updated] = await this.db
- .update(reservationServices)
- .set({ status: 'cancelled', updatedAt: new Date() })
- .where(
- and(eq(reservationServices.id, id), eq(reservationServices.propertyId, propertyId)),
- )
- .returning();
+ const [cancelled] = await tx
+ .update(reservationServices)
+ .set({ status: 'cancelled', updatedAt: new Date() })
+ .where(and(
+ eq(reservationServices.id, id),
+ eq(reservationServices.propertyId, propertyId),
+ eq(reservationServices.reservationId, reservationId),
+ inArray(reservationServices.status, ['quoted', 'confirmed'] as any),
+ ))
+ .returning();
+ if (!cancelled) {
+ throw new ConflictException(
+ `Reservation service ${id} changed while it was being cancelled`,
+ );
+ }
+ return cancelled;
+ },
+ );
await this.webhookService.emit(
'reservation.service_cancelled',
@@ -354,10 +391,19 @@ export class AncillaryService {
* Attach package rate-plan components that are not yet on the reservation.
* Intended to be called from check-in / book flows.
*/
- async ensurePackageComponents(reservationId: string, propertyId: string) {
- const reservation = await this.findReservation(reservationId, propertyId);
-
- const components = await this.db
+ async ensurePackageComponents(
+ reservationId: string,
+ propertyId: string,
+ tx?: any,
+ acceptedPricing?: {
+ freezeUnquotedAtZero: true;
+ currencyCode: string;
+ },
+ ) {
+ const db = tx ?? this.db;
+ const reservation = await this.findReservation(reservationId, propertyId, db);
+
+ const components = await db
.select()
.from(ratePlanComponents)
.where(
@@ -371,7 +417,7 @@ export class AncillaryService {
return [];
}
- const existing = await this.db
+ const existing = await db
.select({ serviceId: reservationServices.serviceId })
.from(reservationServices)
.where(
@@ -388,9 +434,15 @@ export class AncillaryService {
continue;
}
- const service = await this.findServiceById(component.serviceId, propertyId);
+ const service = await this.findServiceById(component.serviceId, propertyId, db);
let unitPrice: string;
- if (component.amountOverride != null) {
+ if (acceptedPricing?.freezeUnquotedAtZero) {
+ // Booking-request totals contain only explicitly quoted extras. A rate
+ // package component absent from that immutable quote may still be
+ // attached for operations/event parity, but can never acquire a later
+ // live catalog price and silently exceed the staff-accepted total.
+ unitPrice = '0.00';
+ } else if (component.amountOverride != null) {
unitPrice = component.amountOverride;
} else if (component.includedInRate) {
unitPrice = '0.00';
@@ -398,7 +450,7 @@ export class AncillaryService {
unitPrice = service.price;
}
- const [row] = await this.db
+ const [row] = await db
.insert(reservationServices)
.values({
propertyId,
@@ -406,7 +458,7 @@ export class AncillaryService {
serviceId: service.id,
quantity: component.quantity ?? 1,
unitPrice,
- currencyCode: service.currencyCode,
+ currencyCode: acceptedPricing?.currencyCode ?? service.currencyCode,
status: 'confirmed',
sourceChannel: 'package',
postingRule: service.postingRule,
@@ -414,124 +466,196 @@ export class AncillaryService {
})
.returning();
- await this.webhookService.emit(
- 'reservation.service_attached',
- 'reservation_service',
- row.id,
- {
- reservationId,
- serviceId: service.id,
- serviceName: service.name,
- sourceChannel: 'package',
- quantity: row.quantity,
- unitPrice,
- },
- propertyId,
- );
+ if (!tx) {
+ await this.webhookService.emit(
+ 'reservation.service_attached',
+ 'reservation_service',
+ row.id,
+ reservationServiceAttachedPayload(row, service.name),
+ propertyId,
+ );
+ }
- attached.push(row);
+ attached.push({ ...row, serviceName: service.name });
}
return attached;
}
async postOnceForReservation(reservationId: string, propertyId: string) {
- const reservation = await this.findReservation(reservationId, propertyId);
- const folio = await this.findOpenGuestFolio(reservationId, propertyId);
- if (!folio) {
- throw new BadRequestException(
- `No open guest folio for reservation ${reservationId}`,
- );
- }
-
- const rows = await this.db
- .select({
- rs: reservationServices,
- serviceName: services.name,
- })
- .from(reservationServices)
- .innerJoin(
- services,
- and(
- eq(services.id, reservationServices.serviceId),
- eq(services.propertyId, reservationServices.propertyId),
- ),
- )
- .where(
- and(
- eq(reservationServices.propertyId, propertyId),
- eq(reservationServices.reservationId, reservationId),
- eq(reservationServices.status, 'confirmed' as any),
- inArray(reservationServices.postingRule, ['once', 'included_in_rate'] as any),
- ),
- );
+ const result = await withAcceptedPricingLock(
+ this.db,
+ propertyId,
+ reservationId,
+ async (tx) => {
+ const reservation = await this.findReservation(reservationId, propertyId, tx);
+ const folio = await this.findOpenGuestFolio(reservationId, propertyId, tx);
+ if (!folio) {
+ throw new BadRequestException(
+ `No open guest folio for reservation ${reservationId}`,
+ );
+ }
+ const rows = await tx
+ .select({
+ rs: reservationServices,
+ serviceName: services.name,
+ })
+ .from(reservationServices)
+ .innerJoin(
+ services,
+ and(
+ eq(services.id, reservationServices.serviceId),
+ eq(services.propertyId, reservationServices.propertyId),
+ ),
+ )
+ .where(and(
+ eq(reservationServices.propertyId, propertyId),
+ eq(reservationServices.reservationId, reservationId),
+ ));
+ const posted: any[] = [];
+ const events: Array<{
+ reservationServiceId: string;
+ amount: string;
+ postingRule: string;
+ chargeType: string;
+ }> = [];
+ const folioOutcomes: Array<{ charge: any; wasCreated: boolean }> = [];
+ const serviceDate = reservation.arrivalDate ?? new Date().toISOString().slice(0, 10);
+ const acceptedRows = matchAcceptedReservationServiceRows(
+ reservation.acceptedPricingSnapshot,
+ rows.map(({ rs }: any) => rs),
+ );
- const posted: any[] = [];
- const serviceDate =
- reservation.arrivalDate ?? new Date().toISOString().slice(0, 10);
-
- for (const { rs, serviceName } of rows) {
- if (await this.hasPostedCharge(folio.id, propertyId, rs.id)) {
- if (rs.status === 'confirmed') {
- await this.db
- .update(reservationServices)
- .set({ status: 'posted', updatedAt: new Date() })
- .where(
- and(
+ for (const { rs, serviceName } of rows) {
+ if (rs.status === 'cancelled') continue;
+ const hasAcceptedPricing = reservation.acceptedPricingSnapshot != null;
+ const isAcceptedRow = hasAcceptedPricing
+ && acceptedRows.get(rs.serviceId)?.id === rs.id;
+ if (hasAcceptedPricing && rs.sourceChannel === 'booking_engine' && !isAcceptedRow) {
+ continue;
+ }
+ const acceptedLine = isAcceptedRow
+ ? this.acceptedServiceLine(reservation, rs.serviceId, serviceDate, true)
+ : null;
+ const effectivePostingRule = acceptedLine?.postingRule ?? rs.postingRule;
+ const effectiveChargeType = acceptedLine?.chargeType ?? rs.chargeType;
+ if (isAcceptedRow) {
+ if (!acceptedLine) continue;
+ if (!['once', 'included_in_rate'].includes(effectivePostingRule)) continue;
+ } else if (
+ rs.status !== 'confirmed'
+ || !['once', 'included_in_rate'].includes(effectivePostingRule)
+ ) {
+ continue;
+ }
+ if (!isAcceptedRow && await this.hasPostedCharge(
+ folio.id,
+ propertyId,
+ rs.id,
+ undefined,
+ tx,
+ )) {
+ await tx
+ .update(reservationServices)
+ .set({ status: 'posted', updatedAt: new Date() })
+ .where(and(
+ eq(reservationServices.id, rs.id),
+ eq(reservationServices.propertyId, propertyId),
+ eq(reservationServices.status, 'confirmed' as any),
+ ));
+ continue;
+ }
+
+ const amount = acceptedLine?.amount
+ ?? new Decimal(rs.unitPrice).times(rs.quantity).toFixed(2);
+ let ledgerGroupWasCreated = false;
+ if (new Decimal(amount).greaterThan(0)) {
+ const chargeInput = {
+ propertyId,
+ type: effectiveChargeType,
+ description: `${serviceName} ${this.svcTag(rs.id)}`,
+ amount,
+ currencyCode: acceptedLine?.currencyCode ?? rs.currencyCode,
+ serviceDate: new Date(
+ `${acceptedLine?.date ?? serviceDate}T00:00:00Z`,
+ ).toISOString(),
+ guestId: reservation.guestId,
+ };
+ const outcome = acceptedLine
+ ? await this.folioService.postChargeFromSnapshotWithOutcome(
+ folio.id,
+ chargeInput,
+ acceptedLine.taxAmount,
+ undefined,
+ `accepted-pricing:reservation-service:${rs.id}:once:${acceptedLine.date}`,
+ tx,
+ )
+ : {
+ charge: await this.folioService.postCharge(folio.id, chargeInput, tx),
+ wasCreated: true,
+ };
+ folioOutcomes.push(outcome);
+ ledgerGroupWasCreated = outcome.wasCreated;
+ }
+
+ let updated: any;
+ if (isAcceptedRow && rs.status === 'posted') {
+ // A once service can acquire a new immutable operational date after
+ // a stay amendment. Its row remains posted, while the date-bearing
+ // source key decides whether this revision still needs a group.
+ if (!ledgerGroupWasCreated) continue;
+ updated = rs;
+ } else {
+ [updated] = await tx
+ .update(reservationServices)
+ .set({ status: 'posted', updatedAt: new Date() })
+ .where(and(
eq(reservationServices.id, rs.id),
eq(reservationServices.propertyId, propertyId),
- ),
- );
+ eq(reservationServices.status, 'confirmed' as any),
+ ))
+ .returning();
+ if (!updated) {
+ throw new ConflictException(
+ `Reservation service ${rs.id} changed while posting`,
+ );
+ }
+ }
+ posted.push(updated);
+ events.push({
+ reservationServiceId: rs.id,
+ amount,
+ postingRule: effectivePostingRule,
+ chargeType: effectiveChargeType,
+ });
}
- continue;
- }
-
- const amount = new Decimal(rs.unitPrice).times(rs.quantity).toFixed(2);
- const description = `${serviceName} ${this.svcTag(rs.id)}`;
-
- // FolioService rejects non-positive amounts except adjustments/reversals.
- // Zero-priced included lines are marked posted without a ledger row.
- if (new Decimal(amount).greaterThan(0)) {
- await this.folioService.postCharge(folio.id, {
- propertyId,
- type: rs.chargeType,
- description,
- amount,
- currencyCode: rs.currencyCode,
- serviceDate: new Date(serviceDate + 'T00:00:00Z').toISOString(),
- guestId: reservation.guestId,
- });
- }
-
- const [updated] = await this.db
- .update(reservationServices)
- .set({ status: 'posted', updatedAt: new Date() })
- .where(
- and(
- eq(reservationServices.id, rs.id),
- eq(reservationServices.propertyId, propertyId),
- ),
- )
- .returning();
+ return { folio, posted, events, folioOutcomes };
+ },
+ );
+ for (const outcome of result.folioOutcomes) {
+ await this.folioService.emitSnapshotChargeWebhooks(
+ result.folio.id,
+ propertyId,
+ outcome,
+ );
+ }
+ for (const event of result.events) {
await this.webhookService.emit(
'reservation.service_posted',
'reservation_service',
- rs.id,
+ event.reservationServiceId,
{
reservationId,
- folioId: folio.id,
- amount,
- postingRule: rs.postingRule,
- chargeType: rs.chargeType,
+ folioId: result.folio.id,
+ amount: event.amount,
+ postingRule: event.postingRule,
+ chargeType: event.chargeType,
},
propertyId,
);
-
- posted.push(updated);
}
-
- return { posted, count: posted.length };
+ return { posted: result.posted, count: result.posted.length };
}
async postPerNightForProperty(propertyId: string, businessDate?: string) {
@@ -562,8 +686,6 @@ export class AncillaryService {
.where(
and(
eq(reservationServices.propertyId, propertyId),
- eq(reservationServices.status, 'confirmed' as any),
- eq(reservationServices.postingRule, 'per_night' as any),
inArray(reservations.status, [...IN_HOUSE_STATUSES] as any),
),
);
@@ -574,14 +696,167 @@ export class AncillaryService {
for (const { rs, serviceName, reservation } of rows) {
try {
- if (rs.startDate && date < rs.startDate) {
- skipped.push(rs.id);
+ if (reservation.acceptedPricingSnapshot) {
+ const lockedPost = await withAcceptedPricingLock(
+ this.db,
+ propertyId,
+ reservation.id,
+ async (tx) => {
+ const currentRows = await tx
+ .select({
+ rs: reservationServices,
+ serviceName: services.name,
+ reservation: reservations,
+ })
+ .from(reservationServices)
+ .innerJoin(
+ reservations,
+ and(
+ eq(reservations.id, reservationServices.reservationId),
+ eq(reservations.propertyId, reservationServices.propertyId),
+ ),
+ )
+ .innerJoin(
+ services,
+ and(
+ eq(services.id, reservationServices.serviceId),
+ eq(services.propertyId, reservationServices.propertyId),
+ ),
+ )
+ .where(and(
+ eq(reservationServices.propertyId, propertyId),
+ eq(reservationServices.reservationId, reservation.id),
+ ));
+ const current = currentRows.find(({ rs: candidate }: any) => candidate.id === rs.id);
+ if (!current || current.rs.status === 'cancelled') return null;
+ const acceptedRows = matchAcceptedReservationServiceRows(
+ current.reservation.acceptedPricingSnapshot,
+ currentRows.map(({ rs: candidate }: any) => candidate),
+ );
+ const isAcceptedRow = acceptedRows.get(current.rs.serviceId)?.id === current.rs.id;
+ if (current.rs.sourceChannel === 'booking_engine' && !isAcceptedRow) return null;
+ const acceptedLine = isAcceptedRow
+ ? this.acceptedServiceLine(
+ current.reservation,
+ current.rs.serviceId,
+ date,
+ false,
+ )
+ : null;
+ const postingRule = acceptedLine?.postingRule ?? current.rs.postingRule;
+ const chargeType = acceptedLine?.chargeType ?? current.rs.chargeType;
+ if (isAcceptedRow) {
+ if (!acceptedLine || postingRule !== 'per_night') return null;
+ } else {
+ if (current.rs.status !== 'confirmed' || postingRule !== 'per_night') return null;
+ if (current.rs.startDate && date < current.rs.startDate) return null;
+ if (current.rs.endDate && date > current.rs.endDate) return null;
+ }
+ const folio = await this.findOpenGuestFolio(reservation.id, propertyId, tx);
+ if (!folio) {
+ throw new BadRequestException(
+ `No open guest folio for reservation ${reservation.id}`,
+ );
+ }
+ if (!isAcceptedRow && await this.hasPostedCharge(
+ folio.id, propertyId, current.rs.id, date, tx,
+ )) return null;
+ const amount = acceptedLine?.amount
+ ?? new Decimal(current.rs.unitPrice).times(current.rs.quantity).toFixed(2);
+ if (new Decimal(amount).lessThanOrEqualTo(0)) return null;
+ const chargeInput = {
+ propertyId,
+ type: chargeType,
+ description: `${current.serviceName} ${this.svcTag(current.rs.id)}`,
+ amount,
+ currencyCode: acceptedLine?.currencyCode ?? current.rs.currencyCode,
+ serviceDate: new Date(`${date}T00:00:00Z`).toISOString(),
+ guestId: current.reservation.guestId,
+ };
+ const outcome = acceptedLine
+ ? await this.folioService.postChargeFromSnapshotWithOutcome(
+ folio.id,
+ chargeInput,
+ acceptedLine.taxAmount,
+ undefined,
+ `accepted-pricing:reservation-service:${current.rs.id}:night:${date}`,
+ tx,
+ )
+ : {
+ charge: await this.folioService.postCharge(folio.id, chargeInput, tx),
+ wasCreated: true,
+ };
+ return {
+ folio,
+ reservation: current.reservation,
+ rs: current.rs,
+ amount,
+ postingRule,
+ chargeType,
+ outcome,
+ };
+ },
+ );
+ if (!lockedPost || !lockedPost.outcome.wasCreated) {
+ skipped.push(rs.id);
+ continue;
+ }
+ await this.folioService.emitSnapshotChargeWebhooks(
+ lockedPost.folio.id,
+ propertyId,
+ lockedPost.outcome,
+ );
+ await this.webhookService.emit(
+ 'reservation.service_posted',
+ 'reservation_service',
+ lockedPost.rs.id,
+ {
+ reservationId: lockedPost.reservation.id,
+ folioId: lockedPost.folio.id,
+ amount: lockedPost.amount,
+ businessDate: date,
+ postingRule: lockedPost.postingRule,
+ chargeType: lockedPost.chargeType,
+ chargeId: lockedPost.outcome.charge.id,
+ },
+ propertyId,
+ );
+ posted.push({
+ reservationServiceId: lockedPost.rs.id,
+ chargeId: lockedPost.outcome.charge.id,
+ amount: lockedPost.amount,
+ });
continue;
}
- if (rs.endDate && date > rs.endDate) {
+
+ const acceptedLine = this.acceptedServiceLine(
+ reservation,
+ rs.serviceId,
+ date,
+ false,
+ );
+ const hasAcceptedPricing = reservation.acceptedPricingSnapshot != null;
+ const effectivePostingRule = acceptedLine?.postingRule ?? rs.postingRule;
+ const effectiveChargeType = acceptedLine?.chargeType ?? rs.chargeType;
+ if (hasAcceptedPricing) {
+ if (!acceptedLine || effectivePostingRule !== 'per_night') {
+ skipped.push(rs.id);
+ continue;
+ }
+ } else if (rs.status !== 'confirmed' || effectivePostingRule !== 'per_night') {
skipped.push(rs.id);
continue;
}
+ if (!hasAcceptedPricing) {
+ if (rs.startDate && date < rs.startDate) {
+ skipped.push(rs.id);
+ continue;
+ }
+ if (rs.endDate && date > rs.endDate) {
+ skipped.push(rs.id);
+ continue;
+ }
+ }
const folio = await this.findOpenGuestFolio(reservation.id, propertyId);
if (!folio) {
@@ -592,27 +867,44 @@ export class AncillaryService {
continue;
}
- if (await this.hasPostedCharge(folio.id, propertyId, rs.id, date)) {
+ if (!hasAcceptedPricing && await this.hasPostedCharge(folio.id, propertyId, rs.id, date)) {
skipped.push(rs.id);
continue;
}
-
- const amount = new Decimal(rs.unitPrice).times(rs.quantity).toFixed(2);
+ const amount = acceptedLine?.amount
+ ?? new Decimal(rs.unitPrice).times(rs.quantity).toFixed(2);
if (new Decimal(amount).lessThanOrEqualTo(0)) {
skipped.push(rs.id);
continue;
}
const description = `${serviceName} ${this.svcTag(rs.id)}`;
- const charge = await this.folioService.postCharge(folio.id, {
+ const chargeInput = {
propertyId,
- type: rs.chargeType,
+ type: effectiveChargeType,
description,
amount,
- currencyCode: rs.currencyCode,
+ currencyCode: acceptedLine?.currencyCode ?? rs.currencyCode,
serviceDate: new Date(date + 'T00:00:00Z').toISOString(),
guestId: reservation.guestId,
- });
+ };
+ const outcome = acceptedLine
+ ? await this.folioService.postChargeFromSnapshotWithOutcome(
+ folio.id,
+ chargeInput,
+ acceptedLine.taxAmount,
+ undefined,
+ `accepted-pricing:reservation-service:${rs.id}:night:${date}`,
+ )
+ : {
+ charge: await this.folioService.postCharge(folio.id, chargeInput),
+ wasCreated: true,
+ };
+ if (!outcome.wasCreated) {
+ skipped.push(rs.id);
+ continue;
+ }
+ const charge = outcome.charge;
// Stay confirmed until stay ends — idempotency via charge existence.
await this.webhookService.emit(
@@ -624,7 +916,8 @@ export class AncillaryService {
folioId: folio.id,
amount,
businessDate: date,
- postingRule: 'per_night',
+ postingRule: effectivePostingRule,
+ chargeType: effectiveChargeType,
chargeId: charge.id,
},
propertyId,
@@ -644,4 +937,37 @@ export class AncillaryService {
count: posted.length,
};
}
+
+ private acceptedServiceLine(
+ reservation: any,
+ serviceId: string,
+ date: string,
+ useFirstLine: boolean,
+ ): {
+ date: string;
+ amount: string;
+ taxAmount: string;
+ currencyCode: string;
+ postingRule: string;
+ chargeType: string;
+ } | null {
+ const pricing = reservation.acceptedPricingSnapshot;
+ if (!pricing || !Array.isArray(pricing.services)) return null;
+ const service = pricing.services.find(
+ (candidate: { serviceId?: string }) => candidate.serviceId === serviceId,
+ );
+ if (!service || !Array.isArray(service.lineItems)) return null;
+ const line = service.lineItems.find(
+ (candidate: { date?: string }) => candidate.date === date,
+ ) ?? (useFirstLine ? service.lineItems[0] : undefined);
+ if (!line) return null;
+ return {
+ date: line.date,
+ amount: line.amount,
+ taxAmount: line.taxAmount,
+ currencyCode: pricing.currencyCode,
+ postingRule: service.postingRule,
+ chargeType: service.chargeType,
+ };
+ }
}
diff --git a/apps/api/src/modules/ancillary/reservation-service-event.ts b/apps/api/src/modules/ancillary/reservation-service-event.ts
new file mode 100644
index 00000000..c8cfc6a6
--- /dev/null
+++ b/apps/api/src/modules/ancillary/reservation-service-event.ts
@@ -0,0 +1,23 @@
+export interface ReservationServiceAttachedRow {
+ reservationId: string;
+ serviceId: string;
+ quantity: number;
+ unitPrice: string;
+ postingRule: string;
+ sourceChannel: string;
+}
+
+export function reservationServiceAttachedPayload(
+ row: ReservationServiceAttachedRow,
+ serviceName: string,
+) {
+ return {
+ reservationId: row.reservationId,
+ serviceId: row.serviceId,
+ serviceName,
+ sourceChannel: row.sourceChannel,
+ quantity: row.quantity,
+ unitPrice: row.unitPrice,
+ postingRule: row.postingRule,
+ };
+}
diff --git a/apps/api/src/modules/auth/booking-key.guard.spec.ts b/apps/api/src/modules/auth/booking-key.guard.spec.ts
index ed5b3080..c223111c 100644
--- a/apps/api/src/modules/auth/booking-key.guard.spec.ts
+++ b/apps/api/src/modules/auth/booking-key.guard.spec.ts
@@ -3,6 +3,14 @@ import { UnauthorizedException } from '@nestjs/common';
import { BookingKeyGuard, hashBookingKey } from './booking-key.guard';
vi.mock('@telivityhaip/database', () => ({
+ // `database.module.ts` (imported transitively via `../auth/api-key.guard`
+ // → `DRIZZLE`) now re-exports these two from `@telivityhaip/database`
+ // itself (a single canonical `DRIZZLE` symbol shared across the optional
+ // `@telivityhaip/booking-requests` package boundary) instead of defining
+ // its own local symbol — this narrow mock must supply both so that static
+ // import doesn't throw, even though this test never uses either value.
+ DRIZZLE: Symbol('DRIZZLE-test-mock'),
+ postgresOptionsFromEnv: vi.fn(() => ({})),
bookingEngineCredentials: { keyHash: 'keyHash' },
}));
diff --git a/apps/api/src/modules/auth/permissions.decorator.ts b/apps/api/src/modules/auth/permissions.decorator.ts
index ac83a878..7f023d7b 100644
--- a/apps/api/src/modules/auth/permissions.decorator.ts
+++ b/apps/api/src/modules/auth/permissions.decorator.ts
@@ -1,17 +1,2 @@
-import { SetMetadata } from '@nestjs/common';
-
-export const PERMISSIONS_KEY = 'permissions';
-
-/**
- * Require one or more permission keys (from permissions.catalog.ts) on an
- * endpoint. Enforced by PermissionsGuard. ALL listed keys are required.
- *
- * When AUTH_ENABLED=false the guard is bypassed (the demo grants everything).
- *
- * @example
- * @RequirePermissions('admin.users.manage')
- * @Post('users')
- * create() { ... }
- */
-export const RequirePermissions = (...permissions: string[]) =>
- SetMetadata(PERMISSIONS_KEY, permissions);
+/** Canonical definition lives in @telivityhaip/shared (used by @telivityhaip/booking-requests too). */
+export { RequirePermissions, PERMISSIONS_KEY } from '@telivityhaip/shared';
diff --git a/apps/api/src/modules/auth/public.decorator.ts b/apps/api/src/modules/auth/public.decorator.ts
index 49b525af..cdbc238f 100644
--- a/apps/api/src/modules/auth/public.decorator.ts
+++ b/apps/api/src/modules/auth/public.decorator.ts
@@ -1,14 +1,2 @@
-import { SetMetadata } from '@nestjs/common';
-
-export const IS_PUBLIC_KEY = 'isPublic';
-
-/**
- * Mark an endpoint as public — no JWT required.
- * Use for health checks, Stripe webhooks, Swagger, etc.
- *
- * @example
- * @Public()
- * @Get('health')
- * healthCheck() { ... }
- */
-export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
+/** Canonical definition lives in @telivityhaip/shared (used by @telivityhaip/booking-requests too). */
+export { Public, IS_PUBLIC_KEY } from '@telivityhaip/shared';
diff --git a/apps/api/src/modules/booking-engine/booking-engine-admin.controller.ts b/apps/api/src/modules/booking-engine/booking-engine-admin.controller.ts
index 5d6a5a83..8acc3747 100644
--- a/apps/api/src/modules/booking-engine/booking-engine-admin.controller.ts
+++ b/apps/api/src/modules/booking-engine/booking-engine-admin.controller.ts
@@ -8,10 +8,13 @@ import {
Param,
Query,
ParseUUIDPipe,
+ Headers,
+ BadRequestException,
} from '@nestjs/common';
-import { ApiTags, ApiOperation } from '@nestjs/swagger';
+import { ApiTags, ApiOperation, ApiHeader } from '@nestjs/swagger';
import { Roles } from '../auth/roles.decorator';
import { RequirePermissions } from '../auth/permissions.decorator';
+import { AuditActorCtx, type AuditActor } from '../../common/audit/audit-actor';
import { BookingEngineConfigService } from './booking-engine-config.service';
import { CreateBookingKeyDto, UpdateBookingEngineConfigDto } from './dto/be-admin.dto';
@@ -34,17 +37,24 @@ export class BookingEngineAdminController {
@RequirePermissions('bookingengine.manage')
@ApiOperation({ summary: 'Get the booking-engine config for a property' })
getConfig(@Query('propertyId', new ParseUUIDPipe()) propertyId: string) {
- return this.configService.getConfig(propertyId);
+ return this.configService.getAdminConfig(propertyId);
}
@Patch('config')
@RequirePermissions('bookingengine.manage')
@ApiOperation({ summary: 'Update the booking-engine config (branding / inventory / deposit policy)' })
+ @ApiHeader({
+ name: 'If-Match',
+ required: false,
+ description: 'Strong ETag containing the updatedAt value from the last admin config read',
+ })
updateConfig(
@Query('propertyId', new ParseUUIDPipe()) propertyId: string,
@Body() dto: UpdateBookingEngineConfigDto,
+ @AuditActorCtx() actor: AuditActor,
+ @Headers('if-match') ifMatch?: string,
) {
- return this.configService.updateConfig(propertyId, dto);
+ return this.configService.updateConfig(propertyId, dto, parseConfigVersion(ifMatch), actor);
}
@Get('keys')
@@ -74,3 +84,17 @@ export class BookingEngineAdminController {
return this.configService.revokeKey(propertyId, id);
}
}
+
+function parseConfigVersion(ifMatch?: string): string | undefined {
+ if (ifMatch === undefined) return undefined;
+ const match = /^"([^"\\]+)"$/.exec(ifMatch.trim());
+ if (!match) {
+ throw new BadRequestException('If-Match must be a single strong quoted config version');
+ }
+ const value = match[1]!;
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.valueOf()) || parsed.toISOString() !== value) {
+ throw new BadRequestException('If-Match must contain an ISO config version');
+ }
+ return value;
+}
diff --git a/apps/api/src/modules/booking-engine/booking-engine-config.service.ts b/apps/api/src/modules/booking-engine/booking-engine-config.service.ts
index c2118e3e..d2204309 100644
--- a/apps/api/src/modules/booking-engine/booking-engine-config.service.ts
+++ b/apps/api/src/modules/booking-engine/booking-engine-config.service.ts
@@ -1,10 +1,27 @@
-import { Injectable, Inject, NotFoundException } from '@nestjs/common';
+import {
+ Injectable,
+ Inject,
+ BadRequestException,
+ ConflictException,
+ NotFoundException,
+} from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
import { eq, and, desc } from 'drizzle-orm';
import { randomBytes } from 'node:crypto';
-import { bookingEngineConfig, bookingEngineCredentials } from '@telivityhaip/database';
-import type { DepositPolicy } from '@telivityhaip/database';
+import { isDeepStrictEqual } from 'node:util';
+import { isBookingRequestsEnabled } from '@telivityhaip/shared';
+import { auditLogs, bookingEngineConfig, bookingEngineCredentials } from '@telivityhaip/database';
+import type {
+ BookingFormQuestionDefinition,
+ BookingMode,
+ DepositPolicy,
+ PaymentMethodCollection,
+} from '@telivityhaip/database';
import { DRIZZLE } from '../../database/database.module';
+import { actorFields, type AuditActor } from '../../common/audit/audit-actor';
import { hashBookingKey } from '../auth/booking-key.guard';
+import { resolvePaymentGatewayProvider } from '../payment/payment-gateway.factory';
+import { isSupportedQuestion, validateQuestionDefinitions } from './booking-form-questions';
// Crockford base32 (no I/L/O/U) — unambiguous when copied by a human.
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
@@ -30,21 +47,107 @@ export interface UpdateConfigInput {
depositPolicy?: DepositPolicy;
autoConfirm?: boolean;
stripePublishableKey?: string | null;
+ bookingMode?: BookingMode;
+ paymentMethodCollection?: PaymentMethodCollection;
+ formQuestions?: BookingFormQuestionDefinition[];
+}
+
+function asRecord(value: unknown): Record {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? value as Record
+ : {};
+}
+
+function sanitizeStringArray(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
+}
+
+function sanitizeBookingFormDefinition(value: unknown): Record {
+ const question = asRecord(value);
+ const options = question['options'];
+ return {
+ ...(typeof question['id'] === 'string' ? { id: question['id'] } : {}),
+ ...(typeof question['label'] === 'string' ? { label: question['label'] } : {}),
+ ...(typeof question['type'] === 'string' ? { type: question['type'] } : {}),
+ ...(Array.isArray(options) && options.every((option) => typeof option === 'string')
+ ? { options: [...options] }
+ : {}),
+ ...(typeof question['order'] === 'number' && Number.isFinite(question['order'])
+ ? { order: question['order'] }
+ : {}),
+ ...(typeof question['isActive'] === 'boolean' ? { isActive: question['isActive'] } : {}),
+ ...(typeof question['isRequired'] === 'boolean' ? { isRequired: question['isRequired'] } : {}),
+ };
+}
+
+function sanitizeDepositPolicy(value: unknown): Record {
+ const policy = asRecord(value);
+ return {
+ ...(typeof policy['type'] === 'string' ? { type: policy['type'] } : {}),
+ ...(typeof policy['percentage'] === 'number' && Number.isFinite(policy['percentage'])
+ ? { percentage: policy['percentage'] }
+ : {}),
+ ...(typeof policy['refundable'] === 'boolean' ? { refundable: policy['refundable'] } : {}),
+ };
+}
+
+function normalizeJsonValue(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(normalizeJsonValue);
+ if (!value || typeof value !== 'object') return value;
+ return Object.fromEntries(
+ Object.entries(value)
+ .filter(([, nestedValue]) => nestedValue !== undefined)
+ .map(([key, nestedValue]) => [key, normalizeJsonValue(nestedValue)]),
+ );
+}
+
+/**
+ * Keep the settings operators need to reconstruct a configuration change while
+ * explicitly excluding credential-bearing fields from the immutable audit trail.
+ */
+export function sanitizeBookingEngineConfig(
+ config: typeof bookingEngineConfig.$inferSelect,
+): Record {
+ return {
+ isEnabled: config.isEnabled,
+ displayName: config.displayName,
+ logoMediaId: config.logoMediaId,
+ primaryColor: config.primaryColor,
+ accentColor: config.accentColor,
+ sellableRoomTypeIds: sanitizeStringArray(config.sellableRoomTypeIds),
+ sellableRatePlanIds: sanitizeStringArray(config.sellableRatePlanIds),
+ depositPolicy: sanitizeDepositPolicy(config.depositPolicy),
+ autoConfirm: config.autoConfirm,
+ bookingMode: config.bookingMode,
+ paymentMethodCollection: config.paymentMethodCollection,
+ formQuestions: config.formQuestions.map(sanitizeBookingFormDefinition),
+ };
}
@Injectable()
export class BookingEngineConfigService {
- constructor(@Inject(DRIZZLE) private readonly db: any) {}
+ constructor(
+ @Inject(DRIZZLE) private readonly db: any,
+ private readonly runtimeConfig: ConfigService,
+ ) {}
+
+ private paymentMethodClientMode(): 'mock' | 'stripe' | 'unsupported' {
+ const provider = resolvePaymentGatewayProvider(this.runtimeConfig);
+ if (provider === 'mock' || provider === 'stripe') return provider;
+ return 'unsupported';
+ }
/** Full config row (admin view). Creates a default row on first access. */
- async getConfig(propertyId: string) {
- const [existing] = await this.db
+ async getConfig(propertyId: string, db?: any, lockForUpdate = false) {
+ const conn = db ?? this.db;
+ const query = conn
.select()
.from(bookingEngineConfig)
.where(eq(bookingEngineConfig.propertyId, propertyId));
+ const [existing] = lockForUpdate ? await query.for('update') : await query;
if (existing) return existing;
- const [created] = await this.db
+ const [created] = await conn
.insert(bookingEngineConfig)
.values({ propertyId })
.returning();
@@ -55,8 +158,19 @@ export class BookingEngineConfigService {
* Public-safe config for the widget. Excludes nothing secret (Stripe key here is
* the PUBLISHABLE key only). Returned for the property bound to the booking key.
*/
- async getPublicConfig(propertyId: string) {
- const cfg = await this.getConfig(propertyId);
+ async getPublicConfig(propertyId: string, db?: any, lockForUpdate = false) {
+ const cfg = await this.getConfig(propertyId, db, lockForUpdate);
+ const bookingMode = cfg.bookingMode as BookingMode;
+ const configuredPaymentMethodCollection =
+ cfg.paymentMethodCollection as PaymentMethodCollection;
+ const paymentMethodClientMode = this.paymentMethodClientMode();
+ const formQuestions = validateQuestionDefinitions(
+ cfg.formQuestions ?? [],
+ { allowActiveUnsupported: true },
+ )
+ .filter(isSupportedQuestion)
+ .filter((question) => question.isActive)
+ .sort((a, b) => a.order - b.order);
return {
propertyId: cfg.propertyId,
isEnabled: cfg.isEnabled,
@@ -68,17 +182,141 @@ export class BookingEngineConfigService {
stripePublishableKey: cfg.stripePublishableKey,
sellableRoomTypeIds: cfg.sellableRoomTypeIds as string[],
sellableRatePlanIds: cfg.sellableRatePlanIds as string[],
+ bookingMode,
+ paymentMethodCollection: configuredPaymentMethodCollection,
+ paymentMethodClientMode,
+ formQuestions,
};
}
- async updateConfig(propertyId: string, input: UpdateConfigInput) {
- await this.getConfig(propertyId); // ensure row exists
- const [updated] = await this.db
- .update(bookingEngineConfig)
- .set({ ...input, updatedAt: new Date() })
- .where(eq(bookingEngineConfig.propertyId, propertyId))
- .returning();
- return updated;
+ async getAdminConfig(propertyId: string) {
+ const cfg = await this.getConfig(propertyId);
+ return {
+ ...cfg,
+ paymentMethodClientMode: this.paymentMethodClientMode(),
+ };
+ }
+
+ async updateConfig(
+ propertyId: string,
+ input: UpdateConfigInput,
+ expectedVersion: string | undefined,
+ actor: AuditActor,
+ ) {
+ await this.getConfig(propertyId); // ensure a row exists before locking it
+
+ return this.db.transaction(async (tx: any) => {
+ const [current] = await tx
+ .select()
+ .from(bookingEngineConfig)
+ .where(eq(bookingEngineConfig.propertyId, propertyId))
+ .for('update');
+ if (!current) {
+ throw new NotFoundException(`Booking engine config for property ${propertyId} not found`);
+ }
+
+ const patch = input;
+ const currentUpdatedAt = new Date(current.updatedAt);
+ const expectedUpdatedAt = expectedVersion === undefined ? undefined : new Date(expectedVersion);
+ // If-Match is optional for one rolling-deployment window so legacy
+ // dashboards can still save. Such requests intentionally have reduced
+ // lost-update protection until all clients send the header.
+ if (expectedUpdatedAt !== undefined && (
+ Number.isNaN(expectedUpdatedAt.valueOf())
+ || expectedUpdatedAt.valueOf() !== currentUpdatedAt.valueOf()
+ )) {
+ throw new ConflictException(
+ 'Booking engine settings changed since they were loaded',
+ );
+ }
+
+ const bookingMode = patch.bookingMode ?? current.bookingMode as BookingMode;
+ const paymentMethodCollection = patch.paymentMethodCollection
+ ?? current.paymentMethodCollection as PaymentMethodCollection;
+ const stripePublishableKey = patch.stripePublishableKey === undefined
+ ? current.stripePublishableKey
+ : patch.stripePublishableKey;
+ const formQuestions = patch.formQuestions === undefined
+ ? undefined
+ : validateQuestionDefinitions(patch.formQuestions);
+ const requestedPatch = {
+ ...Object.fromEntries(
+ Object.entries(patch).filter(([, value]) => value !== undefined),
+ ),
+ ...(formQuestions === undefined ? {} : { formQuestions }),
+ };
+ const normalizedPatch = Object.fromEntries(
+ Object.entries(requestedPatch).map(([field, value]) => [field, normalizeJsonValue(value)]),
+ );
+
+ if (Object.entries(normalizedPatch).every(([field, value]) =>
+ isDeepStrictEqual(normalizeJsonValue(current[field]), value))) {
+ return current;
+ }
+
+ const paymentMethodClientMode = this.paymentMethodClientMode();
+
+ // Fail-safe: request mode has no controllers/services/UI to serve it
+ // unless the deployment opted into the optional booking-requests
+ // package (HAIP_BOOKING_REQUESTS=true). Without this gate, a property
+ // could persist bookingMode=request while the module is unloaded, and
+ // BookingEngineService.book() would reject every instant booking with
+ // no request-mode path to replace it. The check uses the *effective*
+ // (merged) bookingMode, so it also blocks unrelated edits on a row
+ // that is already stuck in 'request' from an earlier deployment that
+ // had the package loaded — an operator must explicitly revert
+ // bookingMode to 'instant' (or re-enable the flag) before any other
+ // field on that row can be written. paymentMethodCollection /
+ // formQuestions are otherwise ordinary columns with no behavioral
+ // effect until bookingMode actually flips to 'request', so they can be
+ // pre-configured at any time.
+ if (bookingMode === 'request' && !isBookingRequestsEnabled()) {
+ throw new BadRequestException(
+ 'Request booking mode requires the HAIP_BOOKING_REQUESTS deployment flag to be enabled',
+ );
+ }
+
+ if (bookingMode === 'request'
+ && paymentMethodCollection !== 'disabled'
+ && paymentMethodClientMode === 'unsupported') {
+ throw new BadRequestException(
+ 'Saved card collection is not supported by the configured payment provider',
+ );
+ }
+
+ if (bookingMode === 'request'
+ && paymentMethodCollection !== 'disabled'
+ && paymentMethodClientMode === 'stripe'
+ && (!stripePublishableKey || stripePublishableKey.trim().length === 0)) {
+ throw new BadRequestException(
+ 'A Stripe publishable key is required when request-mode card collection is enabled',
+ );
+ }
+
+ const now = new Date();
+ const nextUpdatedAt = now.valueOf() > currentUpdatedAt.valueOf()
+ ? now
+ : new Date(currentUpdatedAt.valueOf() + 1);
+ const [updated] = await tx
+ .update(bookingEngineConfig)
+ .set({
+ ...normalizedPatch,
+ updatedAt: nextUpdatedAt,
+ })
+ .where(eq(bookingEngineConfig.propertyId, propertyId))
+ .returning();
+ await tx.insert(auditLogs).values({
+ propertyId,
+ action: 'update',
+ entityType: 'booking_engine_config',
+ entityId: updated.id,
+ ...actorFields(actor),
+ previousValue: sanitizeBookingEngineConfig(current),
+ newValue: sanitizeBookingEngineConfig(updated),
+ description: 'Booking engine configuration updated',
+ });
+ return updated;
+ });
}
// --- Publishable keys ---
diff --git a/apps/api/src/modules/booking-engine/booking-engine.module.ts b/apps/api/src/modules/booking-engine/booking-engine.module.ts
index 4245cf3f..2fc49fb2 100644
--- a/apps/api/src/modules/booking-engine/booking-engine.module.ts
+++ b/apps/api/src/modules/booking-engine/booking-engine.module.ts
@@ -40,6 +40,16 @@ import { PolicyModule } from '../policy/policy.module';
BookingEngineScopeGuard,
BookingThrottleGuard,
],
- exports: [BookingEngineConfigService],
+ exports: [
+ BookingEngineService,
+ BookingEngineConfigService,
+ // Exported so `@telivityhaip/booking-requests`'s `BookingRequestModule.forRoot(...)`
+ // can bind its guard-bridge ports to these same singletons via `useExisting`
+ // (see `apps/api/src/booking-requests.bootstrap.ts`) instead of duplicating
+ // credential/scope/rate-limit logic in the package.
+ BookingKeyGuard,
+ BookingEngineScopeGuard,
+ BookingThrottleGuard,
+ ],
})
export class BookingEngineModule {}
diff --git a/apps/api/src/modules/booking-engine/booking-engine.service.spec.ts b/apps/api/src/modules/booking-engine/booking-engine.service.spec.ts
index 9aa6b7cb..29756b05 100644
--- a/apps/api/src/modules/booking-engine/booking-engine.service.spec.ts
+++ b/apps/api/src/modules/booking-engine/booking-engine.service.spec.ts
@@ -15,11 +15,17 @@ function makeService(overrides: Partial> = {}) {
sellableRoomTypeIds: [RT],
sellableRatePlanIds: [RP],
depositPolicy: { type: 'first_night', refundable: true },
+ bookingMode: 'instant',
+ paymentMethodCollection: 'disabled',
+ formQuestions: [],
}),
getConfig: vi.fn().mockResolvedValue({ autoConfirm: false }),
};
const availability = {
- searchAvailability: vi.fn().mockResolvedValue([{ roomTypeId: RT, available: 5 }]),
+ searchAvailability: vi.fn().mockResolvedValue([
+ { roomTypeId: RT, date: '2026-07-01', available: 5 },
+ { roomTypeId: RT, date: '2026-07-02', available: 5 },
+ ]),
};
const ratePlan = {
calculateDerivedRate: vi.fn().mockResolvedValue({ effectiveRate: 100, currency: 'USD' }),
@@ -95,6 +101,126 @@ describe('BookingEngineService.quote', () => {
// first_night policy → total / nights
expect(q.depositDue).toBe('110.00');
});
+
+ it('rejects a stay when any canonical night is absent or sold out', async () => {
+ const { svc, availability } = makeService();
+ availability.searchAvailability.mockResolvedValue([
+ { roomTypeId: RT, date: '2026-07-01', available: 1 },
+ { roomTypeId: RT, date: '2026-07-03', available: 1 },
+ ]);
+
+ await expect(svc.quote(PROP, {
+ roomTypeId: RT,
+ ratePlanId: RP,
+ checkIn: '2026-07-01',
+ checkOut: '2026-07-04',
+ adults: 2,
+ })).rejects.toThrow(/availability/i);
+ });
+
+ it('captures exact per-night service, tax, currency, and posting metadata', async () => {
+ const { svc, ancillary, tax } = makeService();
+ ancillary.findServiceById.mockResolvedValue({
+ id: 'service-parking',
+ code: 'PARK',
+ name: 'Parking',
+ price: '15.00',
+ currencyCode: 'USD',
+ chargeType: 'parking',
+ postingRule: 'per_night',
+ sellChannels: ['booking_engine'],
+ isActive: true,
+ });
+ tax.calculateTaxes.mockImplementation(async (
+ _amount: string,
+ chargeType: string,
+ ) => [{ amount: chargeType === 'room' ? '10.00' : '2.00' }]);
+
+ const quote = await svc.quote(PROP, {
+ roomTypeId: RT,
+ ratePlanId: RP,
+ checkIn: '2026-07-01',
+ checkOut: '2026-07-03',
+ adults: 2,
+ serviceIds: ['service-parking'],
+ });
+
+ expect(quote.services[0]).toMatchObject({
+ serviceId: 'service-parking',
+ chargeType: 'parking',
+ currencyCode: 'USD',
+ postingRule: 'per_night',
+ unitPrice: '15.00',
+ quantity: 2,
+ lineTotal: '30.00',
+ taxTotal: '4.00',
+ lineItems: [
+ { date: '2026-07-01', amount: '15.00', tax: '2.00' },
+ { date: '2026-07-02', amount: '15.00', tax: '2.00' },
+ ],
+ });
+ });
+
+ it('reads the complete authoritative quote through a caller transaction', async () => {
+ const { svc, config, availability, ratePlan, tax, policy } = makeService();
+ const tx = { marker: 'acceptance-transaction' };
+
+ await svc.quote(PROP, {
+ roomTypeId: RT,
+ ratePlanId: RP,
+ checkIn: '2026-07-01',
+ checkOut: '2026-07-03',
+ adults: 2,
+ }, tx);
+
+ expect(config.getPublicConfig).toHaveBeenCalledWith(PROP, tx);
+ expect(ratePlan.findById).toHaveBeenCalledWith(RP, PROP, tx);
+ expect(ratePlan.calculateDerivedRate).toHaveBeenCalledWith(
+ RP,
+ PROP,
+ expect.any(Object),
+ tx,
+ );
+ expect(availability.searchAvailability).toHaveBeenCalledWith(
+ PROP,
+ '2026-07-01',
+ '2026-07-03',
+ RT,
+ tx,
+ );
+ expect(tax.calculateTaxes).toHaveBeenCalledWith(
+ '100.00',
+ 'room',
+ PROP,
+ '2026-07-01',
+ expect.any(Object),
+ tx,
+ );
+ expect(policy.getPolicySummary).toHaveBeenCalledWith(PROP, RP, tx);
+ });
+
+ it('locks mutable config and rate inputs for an acceptance quote', async () => {
+ const { svc, config, ratePlan } = makeService();
+ const tx = { marker: 'locked-acceptance-transaction' };
+
+ await svc.quote(PROP, {
+ roomTypeId: RT,
+ ratePlanId: RP,
+ checkIn: '2026-07-01',
+ checkOut: '2026-07-03',
+ adults: 2,
+ }, tx, { lockForUpdate: true });
+
+ expect(config.getPublicConfig).toHaveBeenCalledWith(PROP, tx, true);
+ expect(ratePlan.findById).toHaveBeenCalledWith(RP, PROP, tx, true);
+ expect(ratePlan.calculateDerivedRate).toHaveBeenCalledWith(
+ RP,
+ PROP,
+ expect.any(Object),
+ tx,
+ true,
+ );
+ });
});
describe('BookingEngineService.book', () => {
@@ -148,6 +274,7 @@ describe('BookingEngineService.book', () => {
const { svc, config } = makeService();
config.getPublicConfig.mockResolvedValue({
isEnabled: true,
+ bookingMode: 'instant',
sellableRoomTypeIds: [],
sellableRatePlanIds: [RP],
depositPolicy: { type: 'first_night', refundable: true },
@@ -166,6 +293,25 @@ describe('BookingEngineService.book', () => {
await expect(svc.book(PROP, bookDto as any)).rejects.toBeInstanceOf(ForbiddenException);
});
+ it('rejects request mode before creating a guest, reservation, folio, or payment', async () => {
+ const { svc, config, guest, reservation, folio, payment } = makeService();
+ config.getPublicConfig.mockResolvedValue({
+ isEnabled: true,
+ bookingMode: 'request',
+ paymentMethodCollection: 'disabled',
+ formQuestions: [],
+ sellableRoomTypeIds: [RT],
+ sellableRatePlanIds: [RP],
+ depositPolicy: { type: 'first_night', refundable: true },
+ });
+
+ await expect(svc.book(PROP, bookDto as any)).rejects.toBeInstanceOf(ForbiddenException);
+ expect(guest.create).not.toHaveBeenCalled();
+ expect(reservation.create).not.toHaveBeenCalled();
+ expect(folio.createAutoFolio).not.toHaveBeenCalled();
+ expect(payment.authorizePayment).not.toHaveBeenCalled();
+ });
+
it('requires a payment token when a deposit is due', async () => {
const { svc } = makeService();
const { paymentToken, ...noToken } = bookDto as any;
@@ -189,4 +335,17 @@ describe('BookingEngineService.quote — rate/room pairing', () => {
svc.quote(PROP, { roomTypeId: RT, ratePlanId: RP, checkIn: '2026-07-01', checkOut: '2026-07-03', adults: 2 } as any),
).rejects.toBeInstanceOf(BadRequestException);
});
+
+ it('rejects duplicate ancillary service IDs before pricing them', async () => {
+ const { svc, ancillary } = makeService();
+ await expect(svc.quote(PROP, {
+ roomTypeId: RT,
+ ratePlanId: RP,
+ checkIn: '2026-07-01',
+ checkOut: '2026-07-03',
+ adults: 2,
+ serviceIds: ['service-parking', 'service-parking'],
+ } as any)).rejects.toThrow(/services.*duplicates/i);
+ expect(ancillary.findServiceById).not.toHaveBeenCalled();
+ });
});
diff --git a/apps/api/src/modules/booking-engine/booking-engine.service.ts b/apps/api/src/modules/booking-engine/booking-engine.service.ts
index 1aed09ca..fbffbb9f 100644
--- a/apps/api/src/modules/booking-engine/booking-engine.service.ts
+++ b/apps/api/src/modules/booking-engine/booking-engine.service.ts
@@ -5,9 +5,13 @@ import { bookings, reservations } from '@telivityhaip/database';
import type { DepositPolicy } from '@telivityhaip/database';
import { DRIZZLE } from '../../database/database.module';
import { ConnectSearchService } from '../connect/connect-search.service';
-import { ConnectBookingService, generateConfirmationToken } from '../connect/connect-booking.service';
+import { ConnectBookingService } from '../connect/connect-booking.service';
+import { generateConfirmationNumber } from '../../common/crypto/confirmation-number';
import { ReservationService } from '../reservation/reservation.service';
-import { AvailabilityService } from '../reservation/availability.service';
+import {
+ assertFullStayAvailability,
+ AvailabilityService,
+} from '../reservation/availability.service';
import { RatePlanService } from '../rate-plan/rate-plan.service';
import { TaxService } from '../tax/tax.service';
import { GuestService } from '../guest/guest.service';
@@ -130,8 +134,16 @@ export class BookingEngineService {
// --- Quote ---
- async quote(propertyId: string, dto: BeQuoteDto) {
- const config = await this.configService.getPublicConfig(propertyId);
+ async quote(
+ propertyId: string,
+ dto: BeQuoteDto,
+ db?: any,
+ options?: { lockForUpdate?: boolean; excludeReservationId?: string },
+ ) {
+ this.assertUniqueServiceIds(dto.serviceIds);
+ const config = options?.lockForUpdate
+ ? await this.configService.getPublicConfig(propertyId, db, true)
+ : await this.configService.getPublicConfig(propertyId, db);
this.assertSellable(config, dto.roomTypeId, dto.ratePlanId);
// Price-tampering guard: `roomTypeId` and `ratePlanId` arrive as two
@@ -139,7 +151,9 @@ export class BookingEngineService {
// individually sellable, so a caller could pair a pricey room type with a
// cheap room's rate plan and be charged the cheap rate. Each rate plan is
// bound to exactly one room type — enforce that they match.
- const ratePlanRow = await this.ratePlanService.findById(dto.ratePlanId, propertyId);
+ const ratePlanRow = options?.lockForUpdate
+ ? await this.ratePlanService.findById(dto.ratePlanId, propertyId, db, true)
+ : await this.ratePlanService.findById(dto.ratePlanId, propertyId, db);
if (ratePlanRow.roomTypeId !== dto.roomTypeId) {
throw new BadRequestException('Rate plan does not apply to the selected room type');
}
@@ -147,23 +161,50 @@ export class BookingEngineService {
const nights = this.nightsBetween(dto.checkIn, dto.checkOut);
// Re-confirm availability for the requested room type.
- const availability = await this.availabilityService.searchAvailability(
- propertyId,
+ const availability = options?.excludeReservationId
+ ? await this.availabilityService.searchAvailability(
+ propertyId,
+ dto.checkIn,
+ dto.checkOut,
+ dto.roomTypeId,
+ db,
+ { excludeReservationId: options.excludeReservationId },
+ )
+ : await this.availabilityService.searchAvailability(
+ propertyId,
+ dto.checkIn,
+ dto.checkOut,
+ dto.roomTypeId,
+ db,
+ );
+ assertFullStayAvailability(
+ availability,
+ dto.roomTypeId,
dto.checkIn,
dto.checkOut,
- dto.roomTypeId,
);
- const avail = availability.find((a: any) => a.roomTypeId === dto.roomTypeId);
- if (!avail || avail.available <= 0) {
- throw new BadRequestException('No availability for the requested room type and dates');
- }
// Authoritative nightly rate via the rate-plan engine (handles derived rates).
- const { effectiveRate, currency } = await this.ratePlanService.calculateDerivedRate(
- dto.ratePlanId,
- propertyId,
- { nights, checkIn: dto.checkIn, checkOut: dto.checkOut, stayDate: dto.checkIn },
- );
+ const rateContext = {
+ nights,
+ checkIn: dto.checkIn,
+ checkOut: dto.checkOut,
+ stayDate: dto.checkIn,
+ };
+ const { effectiveRate, currency } = options?.lockForUpdate
+ ? await this.ratePlanService.calculateDerivedRate(
+ dto.ratePlanId,
+ propertyId,
+ rateContext,
+ db,
+ true,
+ )
+ : await this.ratePlanService.calculateDerivedRate(
+ dto.ratePlanId,
+ propertyId,
+ rateContext,
+ db,
+ );
// Per-night tax via the real tax engine (not a flat property rate).
const nightlyRate = new Decimal(effectiveRate);
@@ -182,6 +223,7 @@ export class BookingEngineService {
propertyId,
serviceDate,
{ numberOfNights: nights, nightNumber: i + 1 },
+ db,
);
const nightTax = taxes.reduce((acc, t) => acc.plus(new Decimal(t.amount)), new Decimal(0));
roomTotal = roomTotal.plus(nightlyRate);
@@ -195,10 +237,13 @@ export class BookingEngineService {
code: string;
name: string;
postingRule: string;
+ chargeType: string;
+ currencyCode: string;
unitPrice: string;
quantity: number;
lineTotal: string;
taxTotal: string;
+ lineItems: Array<{ date: string; amount: string; tax: string }>;
}> = [];
let servicesTotal = new Decimal(0);
let servicesTaxTotal = new Decimal(0);
@@ -209,7 +254,7 @@ export class BookingEngineService {
if (seen.has(serviceId)) continue;
seen.add(serviceId);
- const service = await this.ancillaryService.findServiceById(serviceId, propertyId);
+ const service = await this.ancillaryService.findServiceById(serviceId, propertyId, db);
if (!service.isActive) {
throw new BadRequestException(`Service ${service.code} is not available`);
}
@@ -227,10 +272,13 @@ export class BookingEngineService {
code: service.code,
name: service.name,
postingRule,
+ chargeType: service.chargeType,
+ currencyCode: service.currencyCode,
unitPrice: unitPrice.toFixed(2),
quantity: 1,
lineTotal: '0.00',
taxTotal: '0.00',
+ lineItems: [],
});
continue;
}
@@ -239,6 +287,11 @@ export class BookingEngineService {
const quantity = postingRule === 'per_night' ? nights : 1;
const lineTotal = unitPrice.times(quantity);
let lineTax = new Decimal(0);
+ const serviceLineItems: Array<{
+ date: string;
+ amount: string;
+ tax: string;
+ }> = [];
if (postingRule === 'per_night') {
for (let i = 0; i < nights; i++) {
@@ -251,10 +304,18 @@ export class BookingEngineService {
propertyId,
serviceDate,
{ numberOfNights: nights, nightNumber: i + 1 },
+ db,
);
- lineTax = lineTax.plus(
- taxes.reduce((acc, t) => acc.plus(new Decimal(t.amount)), new Decimal(0)),
+ const nightTax = taxes.reduce(
+ (acc, t) => acc.plus(new Decimal(t.amount)),
+ new Decimal(0),
);
+ lineTax = lineTax.plus(nightTax);
+ serviceLineItems.push({
+ date: serviceDate,
+ amount: unitPrice.toFixed(2),
+ tax: nightTax.toFixed(2),
+ });
}
} else {
const taxes = await this.taxService.calculateTaxes(
@@ -262,8 +323,15 @@ export class BookingEngineService {
service.chargeType,
propertyId,
dto.checkIn,
+ undefined,
+ db,
);
lineTax = taxes.reduce((acc, t) => acc.plus(new Decimal(t.amount)), new Decimal(0));
+ serviceLineItems.push({
+ date: dto.checkIn,
+ amount: lineTotal.toFixed(2),
+ tax: lineTax.toFixed(2),
+ });
}
servicesTotal = servicesTotal.plus(lineTotal);
@@ -273,10 +341,13 @@ export class BookingEngineService {
code: service.code,
name: service.name,
postingRule,
+ chargeType: service.chargeType,
+ currencyCode: service.currencyCode,
unitPrice: unitPrice.toFixed(2),
- quantity: 1,
+ quantity,
lineTotal: lineTotal.toFixed(2),
taxTotal: lineTax.toFixed(2),
+ lineItems: serviceLineItems,
});
}
}
@@ -293,6 +364,7 @@ export class BookingEngineService {
const cancellationPolicy = await this.policyService.getPolicySummary(
propertyId,
dto.ratePlanId,
+ db,
);
return {
@@ -327,6 +399,11 @@ export class BookingEngineService {
if (!config.isEnabled) {
throw new ForbiddenException('Direct booking is not enabled for this property');
}
+ if (config.bookingMode !== 'instant') {
+ throw new ForbiddenException(
+ 'Instant booking is unavailable while booking requests require staff review',
+ );
+ }
this.assertSellable(config, dto.roomTypeId, dto.ratePlanId);
// Enforce rate restrictions (stop-sell / CTA / CTD / min-max LOS). SEARCH only
// surfaces these — the BOOK path is the real gate against booking a closed date.
@@ -360,7 +437,7 @@ export class BookingEngineService {
// 3. Reservation via the canonical path (DNR + FK-ownership + TOCTOU
// availability + emits `reservation.created`). High-entropy confirmation
// number because the guest uses it as a bearer credential.
- const confirmationNumber = `HAIP-${generateConfirmationToken()}`;
+ const confirmationNumber = generateConfirmationNumber();
const reservation = await this.reservationService.create(
{
propertyId,
@@ -541,6 +618,12 @@ export class BookingEngineService {
// --- Helpers ---
+ private assertUniqueServiceIds(serviceIds: string[] | undefined): void {
+ if (serviceIds && new Set(serviceIds).size !== serviceIds.length) {
+ throw new BadRequestException('Selected services must not contain duplicates');
+ }
+ }
+
private assertSellable(config: { sellableRoomTypeIds: string[]; sellableRatePlanIds: string[] }, roomTypeId: string, ratePlanId: string) {
if (!config.sellableRoomTypeIds.includes(roomTypeId)) {
throw new BadRequestException('This room type is not available for direct booking');
diff --git a/apps/api/src/modules/booking-engine/booking-form-questions.spec.ts b/apps/api/src/modules/booking-engine/booking-form-questions.spec.ts
new file mode 100644
index 00000000..94052cef
--- /dev/null
+++ b/apps/api/src/modules/booking-engine/booking-form-questions.spec.ts
@@ -0,0 +1,869 @@
+import { BadRequestException, ConflictException, ValidationPipe } from '@nestjs/common';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { BookingFormQuestion } from '@telivityhaip/database';
+import { UpdateBookingEngineConfigDto } from './dto/be-admin.dto';
+import { BookingEngineAdminController } from './booking-engine-admin.controller';
+import { BookingEngineConfigService } from './booking-engine-config.service';
+import {
+ validateApplicationAnswers,
+ validateQuestionDefinitions,
+} from './booking-form-questions';
+
+const arrivalQuestion: BookingFormQuestion = {
+ id: 'arrival',
+ label: 'Arrival time',
+ type: 'short_text',
+ order: 0,
+ isActive: true,
+ isRequired: true,
+};
+
+const breakfastQuestion: BookingFormQuestion = {
+ id: 'breakfast',
+ label: 'Breakfast preference',
+ type: 'single_select',
+ options: ['Continental', 'Full English'],
+ order: 1,
+ isActive: true,
+ isRequired: false,
+};
+
+const futureInactiveQuestion = {
+ id: '30000000-0000-4000-8000-000000000003',
+ label: 'Legacy satisfaction score',
+ type: 'rating_scale',
+ order: 2,
+ isActive: false,
+ isRequired: false,
+ futureConfig: {
+ authorization: 'Bearer opaque-form-secret',
+ cardNumber: 'opaque-card-number',
+ cvv: '123',
+ signingMaterial: 'opaque-signing-material',
+ clientCertificate: 'opaque-client-certificate',
+ },
+ options: [{ authorization: 'Bearer option-secret' }],
+};
+
+const adminValidationPipe = new ValidationPipe({
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ transform: true,
+});
+
+function validateAdminBody(value: unknown) {
+ return adminValidationPipe.transform(value, {
+ type: 'body',
+ metatype: UpdateBookingEngineConfigDto,
+ });
+}
+
+describe('validateQuestionDefinitions', () => {
+ it('rejects duplicate question ids and missing select options', () => {
+ expect(() => validateQuestionDefinitions([
+ { id: 'purpose', label: 'Purpose', type: 'single_select', options: [], order: 0, isActive: true, isRequired: true },
+ { id: 'purpose', label: 'Again', type: 'short_text', order: 1, isActive: true, isRequired: false },
+ ])).toThrow(BadRequestException);
+ });
+
+ it('rejects options that only differ by surrounding whitespace or case', () => {
+ expect(() => validateQuestionDefinitions([
+ {
+ id: 'transport',
+ label: 'Transport',
+ type: 'multi_select',
+ options: ['Taxi', ' taxi '],
+ order: 0,
+ isActive: true,
+ isRequired: false,
+ },
+ ])).toThrow(/duplicate option/i);
+ });
+
+ it('rejects more than fifty question definitions', () => {
+ const questions = Array.from({ length: 51 }, (_, order) => ({
+ id: `question-${order}`,
+ label: `Question ${order}`,
+ type: 'short_text' as const,
+ order,
+ isActive: true,
+ isRequired: false,
+ }));
+
+ expect(() => validateQuestionDefinitions(questions)).toThrow(/50/);
+ });
+
+ it('keeps valid definitions in their configured order', () => {
+ const questions = [breakfastQuestion, arrivalQuestion];
+
+ expect(validateQuestionDefinitions(questions)).toEqual(questions);
+ });
+});
+
+describe('validateApplicationAnswers', () => {
+ it('rejects a missing required answer', () => {
+ expect(() => validateApplicationAnswers([arrivalQuestion], {})).toThrow(/Arrival time/);
+ });
+
+ it('accepts values matching each question type', () => {
+ const questions: BookingFormQuestion[] = [
+ arrivalQuestion,
+ breakfastQuestion,
+ { id: 'dietary', label: 'Dietary needs', type: 'multi_select', options: ['Vegan', 'Gluten-free'], order: 2, isActive: true, isRequired: true },
+ { id: 'late', label: 'Late arrival', type: 'yes_no', order: 3, isActive: true, isRequired: true },
+ { id: 'birthday', label: 'Birthday', type: 'date', order: 4, isActive: true, isRequired: false },
+ { id: 'notes', label: 'Notes', type: 'long_text', order: 5, isActive: true, isRequired: false },
+ { id: 'retired', label: 'Retired', type: 'short_text', order: 6, isActive: false, isRequired: true },
+ ];
+ const answers = {
+ arrival: '22:00',
+ breakfast: 'Continental',
+ dietary: ['Vegan', 'Gluten-free'],
+ late: false,
+ birthday: '1990-12-31',
+ notes: 'Please call on arrival.',
+ };
+
+ expect(validateApplicationAnswers(questions, answers)).toEqual(answers);
+ });
+
+ it('rejects answers with the wrong type, unsupported options, or inactive question ids', () => {
+ const questions: BookingFormQuestion[] = [
+ breakfastQuestion,
+ { id: 'late', label: 'Late arrival', type: 'yes_no', order: 1, isActive: true, isRequired: false },
+ { id: 'retired', label: 'Retired', type: 'short_text', order: 2, isActive: false, isRequired: false },
+ ];
+
+ expect(() => validateApplicationAnswers(questions, { breakfast: ['Continental'] })).toThrow(/Breakfast preference/);
+ expect(() => validateApplicationAnswers(questions, { breakfast: 'Vegan' })).toThrow(/Breakfast preference/);
+ expect(() => validateApplicationAnswers(questions, { late: 'yes' })).toThrow(/Late arrival/);
+ expect(() => validateApplicationAnswers(questions, { retired: 'legacy answer' })).toThrow(/retired/i);
+ });
+
+ it('treats blank values as omissions only for optional text and multi-select questions', () => {
+ const questions: BookingFormQuestion[] = [
+ { id: 'notes', label: 'Notes', type: 'long_text', order: 0, isActive: true, isRequired: false },
+ { id: 'dietary', label: 'Dietary needs', type: 'multi_select', options: ['Vegan'], order: 1, isActive: true, isRequired: false },
+ { id: 'late', label: 'Late arrival', type: 'yes_no', order: 2, isActive: true, isRequired: false },
+ { id: 'birthday', label: 'Birthday', type: 'date', order: 3, isActive: true, isRequired: false },
+ ];
+
+ expect(validateApplicationAnswers(questions, { notes: '', dietary: [] })).toEqual({});
+ expect(() => validateApplicationAnswers(questions, { dietary: '' })).toThrow(/Dietary needs/);
+ expect(() => validateApplicationAnswers(questions, { late: [] })).toThrow(/Late arrival/);
+ expect(() => validateApplicationAnswers(questions, { birthday: [] })).toThrow(/Birthday/);
+ });
+});
+
+describe('booking form DTO validation', () => {
+ it('validates nested question ids and limits the form to fifty definitions', async () => {
+ const malformed = {
+ formQuestions: [{
+ id: 'not-a-uuid',
+ label: 'Purpose',
+ type: 'single_select',
+ options: ['Leisure'],
+ order: 0,
+ isActive: true,
+ isRequired: true,
+ }],
+ };
+ const oversized = {
+ formQuestions: Array.from({ length: 51 }, (_, order) => ({
+ id: `00000000-0000-4000-8000-${String(order).padStart(12, '0')}`,
+ label: `Question ${order}`,
+ type: 'short_text',
+ order,
+ isActive: true,
+ isRequired: false,
+ })),
+ };
+
+ await expect(validateAdminBody(malformed)).rejects.toBeInstanceOf(BadRequestException);
+ await expect(validateAdminBody(oversized)).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('accepts legacy update bodies without a version and rejects the removed body token', async () => {
+ const legacy = await validateAdminBody({ displayName: 'Renamed hotel' });
+
+ expect(legacy).toMatchObject({ displayName: 'Renamed hotel' });
+ await expect(validateAdminBody({
+ displayName: 'Renamed hotel',
+ expectedUpdatedAt: '2026-08-25T00:00:00.000Z',
+ })).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('preserves opaque inactive future questions but rejects active unknown types', async () => {
+ const validated = await validateAdminBody({ formQuestions: [futureInactiveQuestion] });
+
+ expect(validated.formQuestions).toEqual([futureInactiveQuestion]);
+ await expect(validateAdminBody({
+ formQuestions: [{ ...futureInactiveQuestion, isActive: true }],
+ })).rejects.toBeInstanceOf(BadRequestException);
+ });
+});
+
+function makeConfigService(
+ row: Record,
+ paymentGateway: 'mock' | 'stripe' | 'adyen' = 'stripe',
+ options: { auditInsertError?: Error } = {},
+) {
+ let persistedRow = row;
+ let stagedRow: Record | undefined;
+ let stagedAudits: Record[] = [];
+ const returning = vi.fn().mockImplementation(async () => [stagedRow]);
+ const where = vi.fn().mockReturnValue({ returning });
+ const set = vi.fn().mockImplementation((values) => {
+ stagedRow = { ...persistedRow, ...values };
+ return { where };
+ });
+ const update = vi.fn().mockReturnValue({ set });
+ const selectWhere = vi.fn().mockImplementation(async () => [persistedRow]);
+ const from = vi.fn().mockReturnValue({ where: selectWhere });
+ const select = vi.fn().mockReturnValue({ from });
+ const lock = vi.fn().mockImplementation(async () => [persistedRow]);
+ const lockedWhere = vi.fn().mockReturnValue({ for: lock });
+ const lockedFrom = vi.fn().mockReturnValue({ where: lockedWhere });
+ const lockedSelect = vi.fn().mockReturnValue({ from: lockedFrom });
+ const storedAudits: Record[] = [];
+ const insertValues = vi.fn().mockImplementation(async (values) => {
+ if (options.auditInsertError) throw options.auditInsertError;
+ stagedAudits.push(values);
+ });
+ const insert = vi.fn().mockReturnValue({ values: insertValues });
+ const tx = { select: lockedSelect, update, insert };
+ const transaction = vi.fn(async (callback: (transaction: typeof tx) => Promise) => {
+ stagedRow = undefined;
+ stagedAudits = [];
+ try {
+ const result = await callback(tx);
+ if (stagedRow) persistedRow = stagedRow;
+ storedAudits.push(...stagedAudits);
+ return result;
+ } finally {
+ stagedRow = undefined;
+ stagedAudits = [];
+ }
+ });
+ const db = { select, update, transaction };
+ const runtimeConfig = {
+ get: (key: string, fallback?: string) => {
+ if (key === 'PAYMENT_GATEWAY') return paymentGateway;
+ if (key === 'STRIPE_MODE') return paymentGateway === 'mock' ? 'mock' : 'test';
+ return fallback;
+ },
+ };
+
+ return {
+ service: new BookingEngineConfigService(db as any, runtimeConfig as any),
+ update,
+ set,
+ transaction,
+ lock,
+ storedAudits,
+ persistedConfig: () => persistedRow,
+ };
+}
+
+describe('BookingEngineConfigService request settings', () => {
+ const configRow = {
+ id: 'bbbbbbbb-0000-4000-b000-000000000001',
+ propertyId: 'aaaaaaaa-0000-4000-a000-000000000001',
+ isEnabled: true,
+ displayName: 'Demo Hotel',
+ logoMediaId: null,
+ primaryColor: '#000000',
+ accentColor: '#ffffff',
+ depositPolicy: {
+ type: 'first_night' as const,
+ refundable: true,
+ authorization: 'Bearer deposit-secret',
+ },
+ stripePublishableKey: 'pk_test_123',
+ sellableRoomTypeIds: ['room-type-1', { authorization: 'Bearer room-list-secret' }],
+ sellableRatePlanIds: ['rate-plan-1', { authorization: 'Bearer rate-list-secret' }],
+ autoConfirm: false,
+ bookingMode: 'request' as const,
+ paymentMethodCollection: 'optional' as const,
+ updatedAt: new Date('2026-08-25T00:00:00.000Z'),
+ formQuestions: [
+ { ...arrivalQuestion, order: 2 },
+ { ...breakfastQuestion, order: 1, isActive: false },
+ { ...futureInactiveQuestion, isActive: true },
+ { id: 'notes', label: 'Notes', type: 'long_text' as const, order: 3, isActive: true, isRequired: false },
+ ],
+ };
+ const auditActor = {
+ userId: 'cccccccc-0000-4000-c000-000000000001',
+ userEmail: 'operator@example.com',
+ ipAddress: '203.0.113.10',
+ };
+
+ // These fixtures simulate a deployment where the optional booking-requests
+ // package is installed and loaded (HAIP_BOOKING_REQUESTS=true). The
+ // fail-safe gate that rejects bookingMode=request without the flag has its
+ // own describe block below.
+ beforeEach(() => {
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', 'true');
+ });
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('returns public request settings with only active questions in display order', async () => {
+ const { service } = makeConfigService(configRow);
+
+ const publicConfig = await service.getPublicConfig(configRow.propertyId);
+ expect(publicConfig).toMatchObject({
+ bookingMode: 'request',
+ paymentMethodCollection: 'optional',
+ paymentMethodClientMode: 'stripe',
+ formQuestions: [
+ { id: 'arrival', order: 2 },
+ { id: 'notes', order: 3 },
+ ],
+ });
+ expect(publicConfig).not.toHaveProperty('updatedAt');
+ });
+
+ it('returns an unsupported legacy required-card policy unchanged to the public flow', async () => {
+ const { service } = makeConfigService(
+ { ...configRow, paymentMethodCollection: 'required' },
+ 'adyen',
+ );
+
+ await expect(service.getPublicConfig(configRow.propertyId)).resolves.toMatchObject({
+ bookingMode: 'request',
+ paymentMethodCollection: 'required',
+ paymentMethodClientMode: 'unsupported',
+ });
+ });
+
+ it('records one sanitized actor-attributed audit entry for a successful request configuration update', async () => {
+ const { service, storedAudits } = makeConfigService(configRow);
+ const updatedQuestions = [{
+ id: '20000000-0000-4000-8000-000000000002',
+ label: 'Arrival time',
+ type: 'short_text' as const,
+ order: 0,
+ isActive: true,
+ isRequired: true,
+ }];
+
+ await service.updateConfig(configRow.propertyId, {
+ bookingMode: 'request',
+ paymentMethodCollection: 'required',
+ formQuestions: updatedQuestions,
+ stripePublishableKey: 'pk_test_replacement',
+ }, configRow.updatedAt.toISOString(), auditActor);
+
+ expect(storedAudits).toEqual([expect.objectContaining({
+ propertyId: configRow.propertyId,
+ action: 'update',
+ entityType: 'booking_engine_config',
+ entityId: configRow.id,
+ userId: auditActor.userId,
+ userEmail: auditActor.userEmail,
+ ipAddress: auditActor.ipAddress,
+ description: 'Booking engine configuration updated',
+ previousValue: expect.objectContaining({
+ bookingMode: 'request',
+ paymentMethodCollection: 'optional',
+ sellableRoomTypeIds: ['room-type-1'],
+ sellableRatePlanIds: ['rate-plan-1'],
+ formQuestions: [
+ { ...arrivalQuestion, order: 2 },
+ { ...breakfastQuestion, order: 1, isActive: false },
+ {
+ id: futureInactiveQuestion.id,
+ label: futureInactiveQuestion.label,
+ type: futureInactiveQuestion.type,
+ order: futureInactiveQuestion.order,
+ isActive: true,
+ isRequired: futureInactiveQuestion.isRequired,
+ },
+ { id: 'notes', label: 'Notes', type: 'long_text', order: 3, isActive: true, isRequired: false },
+ ],
+ }),
+ newValue: expect.objectContaining({
+ bookingMode: 'request',
+ paymentMethodCollection: 'required',
+ formQuestions: updatedQuestions,
+ }),
+ })]);
+ expect(storedAudits[0]?.['previousValue']).not.toHaveProperty('stripePublishableKey');
+ expect(storedAudits[0]?.['newValue']).not.toHaveProperty('stripePublishableKey');
+ expect(JSON.stringify(storedAudits[0])).not.toContain('pk_test_123');
+ expect(JSON.stringify(storedAudits[0])).not.toContain('pk_test_replacement');
+ expect(JSON.stringify(storedAudits[0])).not.toContain('opaque-form-secret');
+ expect(JSON.stringify(storedAudits[0])).not.toContain('opaque-card-number');
+ expect(JSON.stringify(storedAudits[0])).not.toContain('opaque-signing-material');
+ expect(JSON.stringify(storedAudits[0])).not.toContain('opaque-client-certificate');
+ expect(JSON.stringify(storedAudits[0])).not.toContain('option-secret');
+ expect(JSON.stringify(storedAudits[0])).not.toContain('deposit-secret');
+ expect(JSON.stringify(storedAudits[0])).not.toContain('room-list-secret');
+ expect(JSON.stringify(storedAudits[0])).not.toContain('rate-list-secret');
+ expect((storedAudits[0]?.['previousValue'] as Record)['depositPolicy'])
+ .toEqual({ type: 'first_night', refundable: true });
+ });
+
+ it('returns the locked configuration without updating or auditing an empty patch', async () => {
+ const { service, update, storedAudits } = makeConfigService(configRow);
+
+ await expect(service.updateConfig(
+ configRow.propertyId,
+ {},
+ configRow.updatedAt.toISOString(),
+ auditActor,
+ )).resolves.toEqual(configRow);
+
+ expect(update).not.toHaveBeenCalled();
+ expect(storedAudits).toEqual([]);
+ });
+
+ it('returns a legacy unsupported configuration unchanged for an empty patch', async () => {
+ const legacyConfig = {
+ ...configRow,
+ paymentMethodCollection: 'required' as const,
+ stripePublishableKey: null,
+ };
+ const { service, update, storedAudits } = makeConfigService(legacyConfig, 'adyen');
+
+ await expect(service.updateConfig(
+ legacyConfig.propertyId,
+ {},
+ legacyConfig.updatedAt.toISOString(),
+ auditActor,
+ )).resolves.toEqual(legacyConfig);
+
+ expect(update).not.toHaveBeenCalled();
+ expect(storedAudits).toEqual([]);
+ });
+
+ it('returns the locked configuration without updating normalized values already persisted', async () => {
+ const normalizedRow = {
+ ...configRow,
+ formQuestions: [{
+ id: '20000000-0000-4000-8000-000000000002',
+ label: 'Travel purpose',
+ type: 'single_select' as const,
+ options: ['Leisure', 'Business'],
+ order: 0,
+ isActive: true,
+ isRequired: true,
+ }],
+ };
+ const { service, update, storedAudits } = makeConfigService(normalizedRow);
+
+ await expect(service.updateConfig(normalizedRow.propertyId, {
+ formQuestions: [{
+ ...normalizedRow.formQuestions[0],
+ label: ' Travel purpose ',
+ options: [' Leisure ', 'Business'],
+ }],
+ }, normalizedRow.updatedAt.toISOString(), auditActor)).resolves.toEqual(normalizedRow);
+
+ expect(update).not.toHaveBeenCalled();
+ expect(storedAudits).toEqual([]);
+ });
+
+ it('treats a transformed deposit-policy DTO equal to the persisted JSON as a no-op', async () => {
+ const persistedConfig = {
+ ...configRow,
+ depositPolicy: { type: 'percentage' as const, percentage: 25, refundable: true },
+ };
+ const { service, update, storedAudits } = makeConfigService(persistedConfig);
+ const controller = new BookingEngineAdminController(service);
+ const dto = await validateAdminBody({
+ depositPolicy: { type: 'percentage', percentage: 25, refundable: true },
+ });
+
+ await expect(controller.updateConfig(
+ persistedConfig.propertyId,
+ dto,
+ auditActor,
+ `"${persistedConfig.updatedAt.toISOString()}"`,
+ )).resolves.toEqual(persistedConfig);
+
+ expect(update).not.toHaveBeenCalled();
+ expect(storedAudits).toEqual([]);
+ });
+
+ it('rolls back the configuration mutation when its audit insert fails', async () => {
+ const auditFailure = new Error('audit storage unavailable');
+ const { service, persistedConfig, storedAudits } = makeConfigService(
+ configRow,
+ 'stripe',
+ { auditInsertError: auditFailure },
+ );
+
+ await expect(service.updateConfig(
+ configRow.propertyId,
+ { displayName: 'Uncommitted rename' },
+ configRow.updatedAt.toISOString(),
+ auditActor,
+ )).rejects.toThrow(auditFailure);
+
+ expect(persistedConfig()).toEqual(configRow);
+ expect(storedAudits).toEqual([]);
+ });
+
+ it('accepts a legacy admin save without a version during the compatibility window', async () => {
+ const { service, set } = makeConfigService(configRow);
+
+ await service.updateConfig(
+ configRow.propertyId,
+ { displayName: 'Legacy admin name' },
+ undefined,
+ auditActor,
+ );
+
+ expect(set.mock.calls[0][0]).toMatchObject({ displayName: 'Legacy admin name' });
+ });
+
+ it('rejects a stale If-Match version under the row lock before writing or auditing', async () => {
+ const { service, update, lock, storedAudits } = makeConfigService(configRow);
+
+ await expect(service.updateConfig(configRow.propertyId, {
+ displayName: 'Stale admin name',
+ }, '2026-08-24T23:59:59.000Z', auditActor)).rejects.toBeInstanceOf(ConflictException);
+
+ expect(lock).toHaveBeenCalledWith('update');
+ expect(update).not.toHaveBeenCalled();
+ expect(storedAudits).toEqual([]);
+ });
+
+ it('writes only a partial patch when the If-Match version is current', async () => {
+ const { service, set } = makeConfigService(configRow);
+
+ await service.updateConfig(configRow.propertyId, {
+ displayName: 'Renamed Hotel',
+ }, configRow.updatedAt.toISOString(), auditActor);
+
+ const written = set.mock.calls[0][0];
+ expect(written).toMatchObject({ displayName: 'Renamed Hotel' });
+ expect(written).not.toHaveProperty('bookingMode');
+ expect(written).not.toHaveProperty('paymentMethodCollection');
+ expect(written).not.toHaveProperty('formQuestions');
+ });
+
+ it.each(['required', 'optional'] as const)(
+ 'rejects %s Stripe card collection without a publishable card key',
+ async (paymentMethodCollection) => {
+ const { service, update, storedAudits } = makeConfigService({
+ ...configRow,
+ paymentMethodCollection: 'disabled',
+ stripePublishableKey: null,
+ });
+
+ await expect(service.updateConfig(configRow.propertyId, {
+ paymentMethodCollection,
+ }, configRow.updatedAt.toISOString(), auditActor)).rejects.toThrow(/publishable/i);
+ expect(update).not.toHaveBeenCalled();
+ expect(storedAudits).toEqual([]);
+ },
+ );
+
+ it('allows mock card collection without Stripe keys', async () => {
+ const { service, set } = makeConfigService(
+ { ...configRow, stripePublishableKey: null },
+ 'mock',
+ );
+
+ await service.updateConfig(configRow.propertyId, {
+ paymentMethodCollection: 'required',
+ }, configRow.updatedAt.toISOString(), auditActor);
+
+ expect(set.mock.calls[0][0]).toMatchObject({ paymentMethodCollection: 'required' });
+ });
+
+ it.each(['required', 'optional'] as const)(
+ 'rejects %s card collection when the configured provider does not support saved cards',
+ async (paymentMethodCollection) => {
+ const { service, update, storedAudits } = makeConfigService({
+ ...configRow,
+ paymentMethodCollection: 'disabled',
+ }, 'adyen');
+
+ await expect(service.updateConfig(configRow.propertyId, {
+ paymentMethodCollection,
+ }, configRow.updatedAt.toISOString(), auditActor)).rejects.toThrow(/not supported/i);
+ expect(update).not.toHaveBeenCalled();
+ expect(storedAudits).toEqual([]);
+ },
+ );
+
+ it('allows disabled card collection with an unsupported payment provider', async () => {
+ const { service, set } = makeConfigService(configRow, 'adyen');
+
+ await service.updateConfig(configRow.propertyId, {
+ paymentMethodCollection: 'disabled',
+ }, configRow.updatedAt.toISOString(), auditActor);
+
+ expect(set.mock.calls[0][0]).toMatchObject({ paymentMethodCollection: 'disabled' });
+ });
+
+ it('does not write absent request settings during a branding-only update', async () => {
+ const { service, set } = makeConfigService(configRow);
+
+ await service.updateConfig(configRow.propertyId, {
+ displayName: 'Renamed Hotel',
+ bookingMode: undefined,
+ paymentMethodCollection: undefined,
+ formQuestions: undefined,
+ }, configRow.updatedAt.toISOString(), auditActor);
+
+ const written = set.mock.calls[0][0];
+ expect(written).toMatchObject({ displayName: 'Renamed Hotel' });
+ expect(written).not.toHaveProperty('bookingMode');
+ expect(written).not.toHaveProperty('paymentMethodCollection');
+ expect(written).not.toHaveProperty('formQuestions');
+ });
+
+ it('locks the config row while validating and applying a partial update', async () => {
+ const { service, transaction, lock } = makeConfigService(configRow);
+
+ await service.updateConfig(configRow.propertyId, {
+ bookingMode: 'request',
+ }, configRow.updatedAt.toISOString(), auditActor);
+
+ expect(transaction).toHaveBeenCalledOnce();
+ expect(lock).toHaveBeenCalledWith('update');
+ });
+
+ it('parses a strong If-Match header and forwards the authenticated audit actor', async () => {
+ const { service, set, storedAudits } = makeConfigService(configRow);
+ const controller = new BookingEngineAdminController(service);
+ const actor = {
+ userId: 'cccccccc-0000-4000-c000-000000000001',
+ userEmail: 'operator@example.com',
+ ipAddress: '203.0.113.10',
+ };
+
+ await controller.updateConfig(
+ configRow.propertyId,
+ { displayName: 'Header admin name' },
+ actor,
+ `"${configRow.updatedAt.toISOString()}"`,
+ );
+ expect(set.mock.calls[0][0]).toMatchObject({ displayName: 'Header admin name' });
+ expect(storedAudits[0]).toMatchObject(actor);
+ expect(() => controller.updateConfig(
+ configRow.propertyId,
+ { displayName: 'Malformed header' },
+ actor,
+ 'not-an-etag',
+ )).toThrow(BadRequestException);
+ });
+
+ it('validates and preserves an opaque inactive definition through DTO, controller, and service', async () => {
+ const { service, set } = makeConfigService(configRow);
+ const controller = new BookingEngineAdminController(service);
+ const knownQuestion = {
+ id: '20000000-0000-4000-8000-000000000002',
+ label: ' Travel purpose ',
+ type: 'single_select',
+ options: [' Leisure ', 'Business'],
+ order: 0,
+ isActive: true,
+ isRequired: true,
+ };
+ const dto = await validateAdminBody({
+ formQuestions: [knownQuestion, futureInactiveQuestion],
+ });
+
+ await controller.updateConfig(
+ configRow.propertyId,
+ dto,
+ {},
+ `"${configRow.updatedAt.toISOString()}"`,
+ );
+
+ expect(set.mock.calls[0][0].formQuestions).toEqual([
+ { ...knownQuestion, label: 'Travel purpose', options: ['Leisure', 'Business'] },
+ futureInactiveQuestion,
+ ]);
+ });
+
+ it('accepts a legacy unrelated partial through DTO and controller without resending opaque data', async () => {
+ const { service, set } = makeConfigService(configRow);
+ const controller = new BookingEngineAdminController(service);
+ const dto = await validateAdminBody({ displayName: 'Legacy partial' });
+
+ await controller.updateConfig(configRow.propertyId, dto, auditActor);
+
+ expect(set.mock.calls[0][0]).toMatchObject({ displayName: 'Legacy partial' });
+ expect(set.mock.calls[0][0]).not.toHaveProperty('formQuestions');
+ });
+});
+
+describe('BookingEngineConfigService request-mode deployment fail-safe', () => {
+ const instantConfigRow = {
+ id: 'bbbbbbbb-0000-4000-b000-000000000002',
+ propertyId: 'aaaaaaaa-0000-4000-a000-000000000002',
+ isEnabled: true,
+ displayName: 'Instant Hotel',
+ logoMediaId: null,
+ primaryColor: '#000000',
+ accentColor: '#ffffff',
+ depositPolicy: { type: 'first_night' as const, refundable: true },
+ stripePublishableKey: null,
+ sellableRoomTypeIds: [],
+ sellableRatePlanIds: [],
+ autoConfirm: false,
+ bookingMode: 'instant' as const,
+ paymentMethodCollection: 'disabled' as const,
+ updatedAt: new Date('2026-08-25T00:00:00.000Z'),
+ formQuestions: [],
+ };
+ const auditActor = {
+ userId: 'cccccccc-0000-4000-c000-000000000002',
+ userEmail: 'operator@example.com',
+ ipAddress: '203.0.113.10',
+ };
+
+ // `booking_mode` / `payment_method_collection` / `form_questions` are thin
+ // config-hook columns core keeps declared directly on `booking_engine_config`
+ // (see push-schema-kept-fields.spec.ts) — there is no port to swap out here.
+ // The ONLY fail-safe gate is on the effective `bookingMode` resolving to
+ // 'request' while `HAIP_BOOKING_REQUESTS` is off; `paymentMethodCollection`
+ // and `formQuestions` are otherwise ordinary columns an operator can
+ // pre-configure at any time, since they have no behavioral effect until
+ // `bookingMode` actually flips to 'request'.
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('rejects switching a property to request mode when the deployment flag is off', async () => {
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', '');
+ const { service, update, storedAudits } = makeConfigService(instantConfigRow);
+
+ await expect(service.updateConfig(instantConfigRow.propertyId, {
+ bookingMode: 'request',
+ }, instantConfigRow.updatedAt.toISOString(), auditActor))
+ .rejects.toThrow(/HAIP_BOOKING_REQUESTS/);
+ expect(update).not.toHaveBeenCalled();
+ expect(storedAudits).toEqual([]);
+ });
+
+ it('allows pre-configuring card collection and form questions on an instant-mode property even when the deployment flag is off', async () => {
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', '');
+ const { service, set } = makeConfigService({
+ ...instantConfigRow,
+ stripePublishableKey: 'pk_test_preconfig',
+ });
+
+ await service.updateConfig(instantConfigRow.propertyId, {
+ paymentMethodCollection: 'optional',
+ formQuestions: [arrivalQuestion],
+ }, instantConfigRow.updatedAt.toISOString(), auditActor);
+
+ expect(set.mock.calls[0][0]).toMatchObject({
+ paymentMethodCollection: 'optional',
+ formQuestions: [arrivalQuestion],
+ });
+ });
+
+ it('allows an unrelated branding update on an instant-mode property when the deployment flag is off', async () => {
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', '');
+ const { service, set } = makeConfigService(instantConfigRow);
+
+ await service.updateConfig(instantConfigRow.propertyId, {
+ displayName: 'Renamed instant hotel',
+ }, instantConfigRow.updatedAt.toISOString(), auditActor);
+
+ expect(set.mock.calls[0][0]).toMatchObject({ displayName: 'Renamed instant hotel' });
+ });
+
+ it('rejects any edit on a property whose persisted bookingMode is already request while the flag is off', async () => {
+ // A property can end up here if the deployment flag was disabled after
+ // the property was switched to request mode (e.g. rolling back the
+ // optional package). The fail-safe treats the persisted 'request' value
+ // as a live invariant violation and blocks every write — not just an
+ // attempt to re-affirm request mode — until an operator either
+ // re-enables the flag or explicitly reverts `bookingMode` to 'instant'.
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', '');
+ const staleRequestRow = { ...instantConfigRow, bookingMode: 'request' as const };
+ const { service, update, storedAudits } = makeConfigService(staleRequestRow);
+
+ await expect(service.updateConfig(staleRequestRow.propertyId, {
+ displayName: 'Renamed while stale',
+ }, staleRequestRow.updatedAt.toISOString(), auditActor))
+ .rejects.toThrow(/HAIP_BOOKING_REQUESTS/);
+ expect(update).not.toHaveBeenCalled();
+ expect(storedAudits).toEqual([]);
+ });
+
+ it('allows reverting a stale request-mode row back to instant even when the flag is off', async () => {
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', '');
+ const staleRequestRow = { ...instantConfigRow, bookingMode: 'request' as const };
+ const { service, set } = makeConfigService(staleRequestRow);
+
+ await service.updateConfig(staleRequestRow.propertyId, {
+ bookingMode: 'instant',
+ }, staleRequestRow.updatedAt.toISOString(), auditActor);
+
+ expect(set.mock.calls[0][0]).toMatchObject({ bookingMode: 'instant' });
+ });
+
+ it('allows switching to request mode once the deployment flag is on', async () => {
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', 'true');
+ const { service, set } = makeConfigService(instantConfigRow);
+
+ await service.updateConfig(instantConfigRow.propertyId, {
+ bookingMode: 'request',
+ }, instantConfigRow.updatedAt.toISOString(), auditActor);
+
+ expect(set.mock.calls[0][0]).toMatchObject({ bookingMode: 'request' });
+ });
+
+ it('allows unrelated updates to an instant-mode property when the deployment flag is on', async () => {
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', 'true');
+ const { service, set } = makeConfigService(instantConfigRow);
+
+ await service.updateConfig(instantConfigRow.propertyId, {
+ displayName: 'Renamed while loaded',
+ }, instantConfigRow.updatedAt.toISOString(), auditActor);
+
+ expect(set.mock.calls[0][0]).toMatchObject({ displayName: 'Renamed while loaded' });
+ });
+
+ it('allows switching a request-mode row back to instant mode when the deployment flag is on', async () => {
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', 'true');
+ const staleRequestRow = { ...instantConfigRow, bookingMode: 'request' as const };
+ const { service, set } = makeConfigService(staleRequestRow);
+
+ await service.updateConfig(staleRequestRow.propertyId, {
+ bookingMode: 'instant',
+ }, staleRequestRow.updatedAt.toISOString(), auditActor);
+
+ expect(set.mock.calls[0][0]).toMatchObject({ bookingMode: 'instant' });
+ });
+
+ it('reads return the persisted bookingMode/paymentMethodCollection/formQuestions regardless of the deployment flag', async () => {
+ // Unlike the write-time fail-safe above, reads are a direct passthrough
+ // of the persisted row — there is no port to fall back to defaults
+ // through, so a stale 'request' row remains visible to the admin/public
+ // config reads even while the flag is off (the write-time gate is what
+ // prevents new properties from reaching this state without the flag).
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', '');
+ const staleRequestRow = {
+ ...instantConfigRow,
+ bookingMode: 'request' as const,
+ paymentMethodCollection: 'optional' as const,
+ stripePublishableKey: 'pk_test_stale',
+ formQuestions: [arrivalQuestion],
+ };
+ const { service } = makeConfigService(staleRequestRow);
+
+ await expect(service.getPublicConfig(staleRequestRow.propertyId)).resolves.toMatchObject({
+ bookingMode: 'request',
+ paymentMethodCollection: 'optional',
+ formQuestions: [{ id: 'arrival' }],
+ });
+ await expect(service.getAdminConfig(staleRequestRow.propertyId)).resolves.toMatchObject({
+ bookingMode: 'request',
+ paymentMethodCollection: 'optional',
+ });
+ });
+});
diff --git a/apps/api/src/modules/booking-engine/booking-form-questions.ts b/apps/api/src/modules/booking-engine/booking-form-questions.ts
new file mode 100644
index 00000000..761a61ec
--- /dev/null
+++ b/apps/api/src/modules/booking-engine/booking-form-questions.ts
@@ -0,0 +1,213 @@
+import { BadRequestException } from '@nestjs/common';
+import type {
+ BookingFormQuestion,
+ BookingFormQuestionDefinition,
+ BookingFormQuestionType,
+} from '@telivityhaip/database';
+
+const QUESTION_TYPES: readonly BookingFormQuestionType[] = [
+ 'short_text',
+ 'long_text',
+ 'single_select',
+ 'multi_select',
+ 'yes_no',
+ 'date',
+];
+
+const SELECT_TYPES = new Set(['single_select', 'multi_select']);
+const MAX_QUESTIONS = 50;
+const MAX_LABEL_LENGTH = 200;
+const MAX_OPTIONS = 50;
+
+type RawQuestion = {
+ id?: unknown;
+ label?: unknown;
+ type?: unknown;
+ options?: unknown;
+ order?: unknown;
+ isActive?: unknown;
+ isRequired?: unknown;
+ [key: string]: unknown;
+};
+
+function invalid(message: string): never {
+ throw new BadRequestException(message);
+}
+
+function normalized(value: string): string {
+ return value.trim().toLocaleLowerCase();
+}
+
+function isIsoDate(value: string): boolean {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
+ const date = new Date(`${value}T00:00:00.000Z`);
+ return !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value;
+}
+
+/**
+ * Validates the property-owned application form schema before it is persisted.
+ * UUID validation remains at the HTTP boundary because imported historical form
+ * snapshots may be read through this pure function too.
+ */
+export function isSupportedQuestion(
+ question: BookingFormQuestionDefinition,
+): question is BookingFormQuestion {
+ return QUESTION_TYPES.includes(question.type as BookingFormQuestionType);
+}
+
+export function validateQuestionDefinitions(
+ questions: unknown,
+ { allowActiveUnsupported = false }: { allowActiveUnsupported?: boolean } = {},
+): BookingFormQuestionDefinition[] {
+ if (!Array.isArray(questions)) {
+ invalid('Form questions must be an array');
+ }
+ if (questions.length > MAX_QUESTIONS) {
+ invalid(`A booking form can contain at most ${MAX_QUESTIONS} questions`);
+ }
+
+ const ids = new Set();
+ return questions.map((value) => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ invalid('Each booking form question must be an object');
+ }
+ const question = value as RawQuestion;
+ if (typeof question.id !== 'string' || question.id.trim().length === 0) {
+ invalid('Each booking form question requires an id');
+ }
+ if (ids.has(question.id)) {
+ invalid(`Duplicate booking form question id '${question.id}'`);
+ }
+ ids.add(question.id);
+
+ if (typeof question.label !== 'string' || question.label.trim().length === 0) {
+ invalid(`Question '${question.id}' requires a label`);
+ }
+ if (question.label.length > MAX_LABEL_LENGTH) {
+ invalid(`Question '${question.id}' label is too long`);
+ }
+ if (typeof question.type !== 'string' || question.type.trim().length === 0) {
+ invalid(`Question '${question.label}' requires a type`);
+ }
+ if (typeof question.order !== 'number'
+ || !Number.isInteger(question.order)
+ || question.order < 0) {
+ invalid(`Question '${question.label}' requires a non-negative integer order`);
+ }
+ if (typeof question.isActive !== 'boolean' || typeof question.isRequired !== 'boolean') {
+ invalid(`Question '${question.label}' requires active and required flags`);
+ }
+
+ if (!QUESTION_TYPES.includes(question.type as BookingFormQuestionType)) {
+ if (question.isActive && !allowActiveUnsupported) {
+ invalid(`Question '${question.label}' has an unsupported active type`);
+ }
+ return { ...question } as BookingFormQuestionDefinition;
+ }
+
+ const options = question.options;
+ const type = question.type as BookingFormQuestionType;
+ if (SELECT_TYPES.has(type)) {
+ if (!Array.isArray(options) || options.length === 0) {
+ invalid(`Select question '${question.label}' requires at least one option`);
+ }
+ if (options.length > MAX_OPTIONS) {
+ invalid(`Select question '${question.label}' can contain at most ${MAX_OPTIONS} options`);
+ }
+ const normalizedOptions = new Set();
+ for (const option of options) {
+ if (typeof option !== 'string' || option.trim().length === 0 || option.length > 200) {
+ invalid(`Select question '${question.label}' has an invalid option`);
+ }
+ const key = normalized(option);
+ if (normalizedOptions.has(key)) {
+ invalid(`Select question '${question.label}' has a duplicate option`);
+ }
+ normalizedOptions.add(key);
+ }
+ } else if (options !== undefined) {
+ invalid(`Question '${question.label}' does not support options`);
+ }
+
+ return {
+ id: question.id,
+ label: question.label.trim(),
+ type,
+ ...(options ? { options: options.map((option) => (option as string).trim()) } : {}),
+ order: question.order,
+ isActive: question.isActive,
+ isRequired: question.isRequired,
+ } as BookingFormQuestion;
+ });
+}
+
+/** Validates the public answer payload against the current active form schema. */
+export function validateApplicationAnswers(
+ questions: BookingFormQuestion[],
+ answers: Record,
+): Record {
+ const definitions = validateQuestionDefinitions(questions).filter(isSupportedQuestion);
+ if (!answers || typeof answers !== 'object' || Array.isArray(answers)) {
+ invalid('Application answers must be an object');
+ }
+
+ const activeQuestions = definitions.filter((question) => question.isActive);
+ const byId = new Map(activeQuestions.map((question) => [question.id, question]));
+
+ for (const id of Object.keys(answers)) {
+ if (!byId.has(id)) {
+ invalid(`Answer for inactive or unknown question '${id}' is not allowed`);
+ }
+ }
+
+ const validated: Record = {};
+ for (const question of activeQuestions) {
+ const answer = answers[question.id];
+ if (!Object.prototype.hasOwnProperty.call(answers, question.id)) {
+ if (question.isRequired) {
+ invalid(`${question.label} is required`);
+ }
+ continue;
+ }
+
+ switch (question.type) {
+ case 'short_text':
+ case 'long_text':
+ if (typeof answer !== 'string') invalid(`${question.label} must be text`);
+ if (answer.trim().length === 0) {
+ if (question.isRequired) invalid(`${question.label} is required`);
+ continue;
+ }
+ break;
+ case 'single_select':
+ if (typeof answer !== 'string' || !question.options!.includes(answer)) {
+ invalid(`${question.label} must be one of the configured options`);
+ }
+ break;
+ case 'multi_select':
+ if (!Array.isArray(answer)) {
+ invalid(`${question.label} must contain distinct configured options`);
+ }
+ if (answer.length === 0) {
+ if (question.isRequired) invalid(`${question.label} is required`);
+ continue;
+ }
+ if (answer.some((value) => typeof value !== 'string' || !question.options!.includes(value))
+ || new Set(answer).size !== answer.length) {
+ invalid(`${question.label} must contain distinct configured options`);
+ }
+ break;
+ case 'yes_no':
+ if (typeof answer !== 'boolean') invalid(`${question.label} must be yes or no`);
+ break;
+ case 'date':
+ if (typeof answer !== 'string' || !isIsoDate(answer)) {
+ invalid(`${question.label} must be an ISO date`);
+ }
+ break;
+ }
+ validated[question.id] = answer;
+ }
+
+ return validated;
+}
diff --git a/apps/api/src/modules/booking-engine/booking-throttle.guard.spec.ts b/apps/api/src/modules/booking-engine/booking-throttle.guard.spec.ts
new file mode 100644
index 00000000..799c8863
--- /dev/null
+++ b/apps/api/src/modules/booking-engine/booking-throttle.guard.spec.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import { BookingThrottleGuard } from './booking-throttle.guard';
+
+const PROPERTY_ID = 'aaaaaaaa-0000-4000-a000-000000000001';
+
+describe('BookingThrottleGuard', () => {
+ it('enforces the setup-route throttle guard once its property budget is exhausted', () => {
+ const originalNodeEnv = process.env['NODE_ENV'];
+ process.env['NODE_ENV'] = 'production';
+ try {
+ const config = {
+ get: (key: string, fallback: string) => {
+ if (key === 'BOOKING_RATE_LIMIT_MAX') return '1';
+ if (key === 'BOOKING_RATE_LIMIT_WINDOW_MS') return '60000';
+ if (key === 'RATE_LIMIT_DISABLED') return 'false';
+ return fallback;
+ },
+ } as unknown as ConstructorParameters[0];
+ const guard = new BookingThrottleGuard(config);
+ const context = {
+ switchToHttp: () => ({
+ getRequest: () => ({
+ ip: '203.0.113.10',
+ bookingEngine: { propertyId: PROPERTY_ID },
+ }),
+ }),
+ } as unknown as Parameters[0];
+
+ expect(guard.canActivate(context)).toBe(true);
+ expect(() => guard.canActivate(context)).toThrow(/Too many booking attempts/);
+ } finally {
+ if (originalNodeEnv === undefined) delete process.env['NODE_ENV'];
+ else process.env['NODE_ENV'] = originalNodeEnv;
+ }
+ });
+});
diff --git a/apps/api/src/modules/booking-engine/dto/be-admin.dto.ts b/apps/api/src/modules/booking-engine/dto/be-admin.dto.ts
index aaa6b3f2..e76eb5db 100644
--- a/apps/api/src/modules/booking-engine/dto/be-admin.dto.ts
+++ b/apps/api/src/modules/booking-engine/dto/be-admin.dto.ts
@@ -10,10 +10,30 @@ import {
Max,
MaxLength,
Min,
+ Validate,
ValidateNested,
+ ValidatorConstraint,
+ type ValidatorConstraintInterface,
+ ArrayMaxSize,
+ isUUID,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import type {
+ BookingFormQuestion,
+ BookingFormQuestionDefinition,
+ BookingFormQuestionType,
+} from '@telivityhaip/database';
+import { validateQuestionDefinitions } from '../booking-form-questions';
+
+const BOOKING_FORM_QUESTION_TYPES: BookingFormQuestionType[] = [
+ 'short_text',
+ 'long_text',
+ 'single_select',
+ 'multi_select',
+ 'yes_no',
+ 'date',
+];
export class DepositPolicyDto {
@ApiProperty({ enum: ['none', 'first_night', 'percentage', 'full'] })
@@ -40,6 +60,57 @@ export class CreateBookingKeyDto {
label!: string;
}
+export class BookingFormQuestionDto implements BookingFormQuestion {
+ @ApiProperty({ format: 'uuid' })
+ @IsUUID()
+ id!: string;
+
+ @ApiProperty({ maxLength: 200 })
+ @IsString()
+ @MaxLength(200)
+ label!: string;
+
+ @ApiProperty({ enum: BOOKING_FORM_QUESTION_TYPES })
+ @IsIn(BOOKING_FORM_QUESTION_TYPES)
+ type!: BookingFormQuestionType;
+
+ @ApiPropertyOptional({ type: [String], maxItems: 50 })
+ @IsOptional()
+ @IsArray()
+ @ArrayMaxSize(50)
+ @IsString({ each: true })
+ @MaxLength(200, { each: true })
+ options?: string[];
+
+ @ApiProperty({ minimum: 0 })
+ @IsInt()
+ @Min(0)
+ order!: number;
+
+ @ApiProperty()
+ @IsBoolean()
+ isActive!: boolean;
+
+ @ApiProperty()
+ @IsBoolean()
+ isRequired!: boolean;
+}
+
+@ValidatorConstraint({ name: 'adminBookingFormQuestions', async: false })
+export class AdminBookingFormQuestionsConstraint implements ValidatorConstraintInterface {
+ validate(value: unknown): boolean {
+ try {
+ return validateQuestionDefinitions(value).every((question) => isUUID(question.id));
+ } catch {
+ return false;
+ }
+ }
+
+ defaultMessage(): string {
+ return 'formQuestions contains an invalid or unsupported active question';
+ }
+}
+
/** Admin: update per-property booking engine config. */
export class UpdateBookingEngineConfigDto {
@ApiPropertyOptional()
@@ -91,6 +162,23 @@ export class UpdateBookingEngineConfigDto {
@IsBoolean()
autoConfirm?: boolean;
+ @ApiPropertyOptional({ enum: ['instant', 'request'] })
+ @IsOptional()
+ @IsIn(['instant', 'request'])
+ bookingMode?: 'instant' | 'request';
+
+ @ApiPropertyOptional({ enum: ['required', 'optional', 'disabled'] })
+ @IsOptional()
+ @IsIn(['required', 'optional', 'disabled'])
+ paymentMethodCollection?: 'required' | 'optional' | 'disabled';
+
+ @ApiPropertyOptional({ type: [BookingFormQuestionDto], maxItems: 50 })
+ @IsOptional()
+ @IsArray()
+ @ArrayMaxSize(50)
+ @Validate(AdminBookingFormQuestionsConstraint)
+ formQuestions?: BookingFormQuestionDefinition[];
+
@ApiPropertyOptional({ description: 'Stripe PUBLISHABLE key (safe to expose)' })
@IsOptional()
@IsString()
diff --git a/apps/api/src/modules/booking-engine/dto/be-create-booking.dto.ts b/apps/api/src/modules/booking-engine/dto/be-create-booking.dto.ts
index ab3df13f..9fc7057a 100644
--- a/apps/api/src/modules/booking-engine/dto/be-create-booking.dto.ts
+++ b/apps/api/src/modules/booking-engine/dto/be-create-booking.dto.ts
@@ -1,5 +1,6 @@
import {
IsArray,
+ ArrayUnique,
IsDateString,
IsEmail,
IsInt,
@@ -97,6 +98,7 @@ export class BeCreateBookingDto {
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
+ @ArrayUnique()
@IsUUID('4', { each: true })
serviceIds?: string[];
}
diff --git a/apps/api/src/modules/booking-engine/dto/be-quote.dto.ts b/apps/api/src/modules/booking-engine/dto/be-quote.dto.ts
index 5dc74a01..42d04d88 100644
--- a/apps/api/src/modules/booking-engine/dto/be-quote.dto.ts
+++ b/apps/api/src/modules/booking-engine/dto/be-quote.dto.ts
@@ -1,4 +1,4 @@
-import { IsArray, IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator';
+import { ArrayUnique, IsArray, IsDateString, IsInt, IsOptional, IsUUID, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/** Firm price quote for a specific room type + rate plan + dates + occupancy. */
@@ -33,6 +33,7 @@ export class BeQuoteDto {
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
+ @ArrayUnique()
@IsUUID('4', { each: true })
serviceIds?: string[];
}
diff --git a/apps/api/src/modules/booking-request/booking-request-authorization.spec.ts b/apps/api/src/modules/booking-request/booking-request-authorization.spec.ts
new file mode 100644
index 00000000..6c55dcb8
--- /dev/null
+++ b/apps/api/src/modules/booking-request/booking-request-authorization.spec.ts
@@ -0,0 +1,154 @@
+import {
+ Injectable,
+ ValidationPipe,
+} from '@nestjs/common';
+import type { CanActivate, ExecutionContext, INestApplication } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { APP_GUARD } from '@nestjs/core';
+import { Test } from '@nestjs/testing';
+import request from 'supertest';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import { PermissionsGuard } from '../auth/permissions.guard';
+import { PermissionsService } from '../auth/permissions.service';
+import { BookingEngineAdminController } from '../booking-engine/booking-engine-admin.controller';
+import { BookingEngineConfigService } from '../booking-engine/booking-engine-config.service';
+import {
+ BookingRequestController,
+ BookingRequestMailerService,
+ BookingRequestPaymentService,
+ BookingRequestService,
+} from '@telivityhaip/booking-requests';
+
+const PROPERTY_ID = '11111111-1111-4111-8111-111111111111';
+const REQUEST_ID = '22222222-2222-4222-8222-222222222222';
+const PREVIEW_TOKEN = `v1:${'a'.repeat(64)}`;
+
+const grants: Record = {
+ reader: ['reservations.read'],
+ writer: ['reservations.write'],
+ config: ['bookingengine.manage'],
+ none: [],
+};
+
+@Injectable()
+class AuthenticatedTestPrincipalGuard implements CanActivate {
+ canActivate(context: ExecutionContext): boolean {
+ const req = context.switchToHttp().getRequest<{
+ headers: Record;
+ user?: { sub: string; email: string };
+ }>();
+ const header = req.headers['x-test-user'];
+ const sub = Array.isArray(header) ? header[0] : header;
+ if (sub) req.user = { sub, email: `${sub}@example.com` };
+ return true;
+ }
+}
+
+describe('Booking Request staff authorization contract', () => {
+ let app: INestApplication;
+ const bookingRequests = {
+ list: vi.fn(async () => ({ data: [], page: 1, limit: 20, total: 0 })),
+ findById: vi.fn(async () => ({ id: REQUEST_ID, propertyId: PROPERTY_ID })),
+ accept: vi.fn(async () => ({
+ requestId: REQUEST_ID,
+ status: 'accepted',
+ reservationId: '33333333-3333-4333-8333-333333333333',
+ })),
+ };
+ const bookingEngineConfig = {
+ getAdminConfig: vi.fn(async () => ({
+ propertyId: PROPERTY_ID,
+ bookingMode: 'request',
+ paymentMethodClientMode: 'mock',
+ })),
+ };
+
+ beforeAll(async () => {
+ const moduleRef = await Test.createTestingModule({
+ controllers: [BookingRequestController, BookingEngineAdminController],
+ providers: [
+ { provide: APP_GUARD, useClass: AuthenticatedTestPrincipalGuard },
+ { provide: APP_GUARD, useClass: PermissionsGuard },
+ { provide: ConfigService, useValue: { get: () => 'true' } },
+ {
+ provide: PermissionsService,
+ useValue: {
+ findLocalUser: async (sub?: string) => sub ? { id: sub } : null,
+ getEffectivePermissions: async (userId: string) => grants[userId] ?? [],
+ },
+ },
+ { provide: BookingRequestService, useValue: bookingRequests },
+ { provide: BookingRequestPaymentService, useValue: {} },
+ { provide: BookingRequestMailerService, useValue: {} },
+ { provide: BookingEngineConfigService, useValue: bookingEngineConfig },
+ ],
+ }).compile();
+ app = moduleRef.createNestApplication();
+ app.setGlobalPrefix('api/v1');
+ app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
+ await app.init();
+ });
+
+ afterAll(async () => {
+ await app?.close();
+ });
+
+ it('requires reservations.read for the staff queue and detail surface', async () => {
+ const http = request(app.getHttpServer());
+ await http
+ .get('/api/v1/booking-requests')
+ .query({ propertyId: PROPERTY_ID })
+ .set('x-test-user', 'none')
+ .expect(403);
+ await http
+ .get('/api/v1/booking-requests')
+ .query({ propertyId: PROPERTY_ID })
+ .set('x-test-user', 'reader')
+ .expect(200);
+ await http
+ .get(`/api/v1/booking-requests/${REQUEST_ID}`)
+ .query({ propertyId: PROPERTY_ID })
+ .set('x-test-user', 'none')
+ .expect(403);
+ await http
+ .get(`/api/v1/booking-requests/${REQUEST_ID}`)
+ .query({ propertyId: PROPERTY_ID })
+ .set('x-test-user', 'reader')
+ .expect(200);
+ expect(bookingRequests.list).toHaveBeenCalledOnce();
+ expect(bookingRequests.findById).toHaveBeenCalledOnce();
+ });
+
+ it('requires reservations.write for acceptance even when read is granted', async () => {
+ const http = request(app.getHttpServer());
+ const body = { priceSource: 'current', previewToken: PREVIEW_TOKEN };
+ await http
+ .post(`/api/v1/booking-requests/${REQUEST_ID}/accept`)
+ .query({ propertyId: PROPERTY_ID })
+ .set('x-test-user', 'reader')
+ .send(body)
+ .expect(403);
+ await http
+ .post(`/api/v1/booking-requests/${REQUEST_ID}/accept`)
+ .query({ propertyId: PROPERTY_ID })
+ .set('x-test-user', 'writer')
+ .send(body)
+ .expect(201);
+ expect(bookingRequests.accept).toHaveBeenCalledOnce();
+ });
+
+ it('requires bookingengine.manage for booking engine configuration', async () => {
+ const http = request(app.getHttpServer());
+ await http
+ .get('/api/v1/admin/booking-engine/config')
+ .query({ propertyId: PROPERTY_ID })
+ .set('x-test-user', 'writer')
+ .expect(403);
+ await http
+ .get('/api/v1/admin/booking-engine/config')
+ .query({ propertyId: PROPERTY_ID })
+ .set('x-test-user', 'config')
+ .expect(200);
+ expect(bookingEngineConfig.getAdminConfig).toHaveBeenCalledOnce();
+ });
+});
diff --git a/apps/api/src/modules/booking-request/booking-request-default-flow-regression.spec.ts b/apps/api/src/modules/booking-request/booking-request-default-flow-regression.spec.ts
new file mode 100644
index 00000000..674d20c4
--- /dev/null
+++ b/apps/api/src/modules/booking-request/booking-request-default-flow-regression.spec.ts
@@ -0,0 +1,632 @@
+import { randomBytes, randomUUID } from 'node:crypto';
+import { join } from 'node:path';
+import { Test, type TestingModule } from '@nestjs/testing';
+import {
+ bookingEngineConfig,
+ charges,
+ depositLedgerEntries,
+ folios,
+ payments,
+ properties,
+ ratePlans,
+ reservations,
+ rooms,
+ roomTypes,
+ webhookDeliveries,
+} from '@telivityhaip/database';
+import * as coreSchema from '@telivityhaip/database';
+import {
+ bookingRequestAuditLogs as auditLogs,
+ bookingRequestConsequences,
+ bookingRequestEmailDeliveries,
+ bookingRequestInstallments,
+ bookingRequestPaymentAllocations,
+ bookingRequestPaymentResolutions,
+ bookingRequestStayAmendments,
+ bookingRequests,
+} from '@telivityhaip/booking-requests/schema';
+import * as bookingRequestsSchema from '@telivityhaip/booking-requests/schema';
+import { and, eq } from 'drizzle-orm';
+import { drizzle } from 'drizzle-orm/postgres-js';
+import postgres from 'postgres';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import { DRIZZLE } from '../../database/database.module';
+import { EmailService } from '../agent/guest-comms/email.service';
+import { BookingEngineConfigService } from '../booking-engine/booking-engine-config.service';
+import { BookingEngineService } from '../booking-engine/booking-engine.service';
+import { FolioService } from '../folio/folio.service';
+import { PAYMENT_GATEWAY } from '../payment/interfaces/payment-gateway.interface';
+import type { PaymentGateway } from '../payment/interfaces/payment-gateway.interface';
+import { PaymentService } from '../payment/payment.service';
+import { StripeWebhookController } from '../payment/stripe-webhook.controller';
+import { WebhookService } from '../webhook/webhook.service';
+import { BookingRequestService } from '@telivityhaip/booking-requests';
+import { createRegressionDatabaseHelpers } from './regression-database-utils.js';
+
+const schema = { ...coreSchema, ...bookingRequestsSchema };
+
+const baseDatabaseUrl = process.env['DATABASE_URL'];
+const describeDatabase = baseDatabaseUrl ? describe : describe.skip;
+const connectionTemplate = baseDatabaseUrl
+ ?? 'postgresql://unavailable:unavailable@127.0.0.1:1/haip';
+const { databaseUrlFor, execFileBounded, runDatabaseUtility, sanitizedChildError } =
+ createRegressionDatabaseHelpers(connectionTemplate);
+const PUSH_SCHEMA_TIMEOUT_MS = 60_000;
+
+type Fixture = {
+ propertyId: string;
+ roomTypeId: string;
+ ratePlanId: string;
+ arrivalDate: string;
+ departureDate: string;
+};
+
+type StripeWebhookDriver = {
+ stripe: { webhooks: { constructEvent: () => Record } };
+ webhookSecret: string;
+};
+
+function dateFromNow(days: number): string {
+ const date = new Date();
+ date.setUTCDate(date.getUTCDate() + days);
+ return date.toISOString().slice(0, 10);
+}
+
+describe('default-flow release-gate diagnostic sanitization', () => {
+ it('removes URL userinfo and conninfo passwords while retaining useful context', () => {
+ const leakedUrl = 'postgresql://u:p%27word@host/task8_x?sslmode=require';
+ const error = sanitizedChildError('database schema installation', {
+ status: 1,
+ stderr: Buffer.from(
+ `createdb: ${leakedUrl} failed; password='p\\'word' authentication rejected; `
+ + 'password=foo\\ bar host=db',
+ ),
+ }, "p'word");
+
+ expect(error.message).toContain(
+ 'createdb: postgresql://host/task8_x?sslmode=require failed',
+ );
+ expect(error.message).toContain('password=[redacted] authentication rejected');
+ expect(error.message).not.toContain('u:');
+ expect(error.message).not.toContain('p%27word');
+ expect(error.message).not.toContain("p'word");
+ expect(error.message).not.toContain("p\\'word");
+ expect(error.message).toContain('password=[redacted] host=db');
+ expect(error.message).not.toContain('foo\\ bar');
+
+ const metadataOnlyError = Object.assign(
+ new Error(`spawn failed for ${leakedUrl}; password=foo\\ bar host=db`),
+ { code: 'ENOENT' },
+ );
+ const metadataOnly = sanitizedChildError(
+ 'PostgreSQL createdb',
+ metadataOnlyError,
+ "p'word",
+ );
+ expect(metadataOnly.message).toContain('(code ENOENT)');
+ expect(metadataOnly.message).toContain(
+ 'spawn failed for postgresql://host/task8_x?sslmode=require; '
+ + 'password=[redacted] host=db',
+ );
+ expect(metadataOnly.message).not.toContain('u:p%27word');
+ expect(metadataOnly.message).not.toContain('foo\\ bar');
+
+ const mixedLineEndings = [
+ `password=unquoted\\${'\n'}linefeed host=lf`,
+ `password='single\\${'\r'}carriage' host=cr`,
+ `password="double\\${'\r\n'}pair" host=crlf`,
+ `password=unicode\\${'\u2028'}separator host=unicode`,
+ ].join('; ');
+ const multiline = sanitizedChildError('PostgreSQL dropdb', {
+ status: 1,
+ stderr: Buffer.from(mixedLineEndings),
+ }, 'unrelated-secret');
+ expect(multiline.message).toContain([
+ 'password=[redacted] host=lf',
+ 'password=[redacted] host=cr',
+ 'password=[redacted] host=crlf',
+ 'password=[redacted] host=unicode',
+ ].join('; '));
+ expect(multiline.message).not.toContain('linefeed');
+ expect(multiline.message).not.toContain('carriage');
+ expect(multiline.message).not.toContain('pair');
+ expect(multiline.message).not.toContain('separator');
+ });
+});
+
+describeDatabase('Booking Request default-flow release gate', () => {
+ const databaseName = `task8_default_flow_${randomBytes(10).toString('hex')}`;
+ const scratchDatabaseUrl = databaseUrlFor(databaseName);
+ const client = postgres(scratchDatabaseUrl, { max: 8 });
+ const db = drizzle(client, { schema });
+ const webhookService = {
+ emit: vi.fn(async () => undefined),
+ dispatchPersisted: vi.fn(async () => undefined),
+ };
+ const emailService = {
+ isConfigured: vi.fn(() => true),
+ send: vi.fn(async () => ({
+ sent: true,
+ provider: 'task8-memory',
+ messageId: 'task8-receipt-message',
+ })),
+ };
+ const gateway: PaymentGateway = {
+ authorize: vi.fn(async () => ({ success: true, transactionId: `pi_${randomUUID()}` })),
+ capture: vi.fn(async (transactionId) => ({ success: true, transactionId })),
+ void: vi.fn(async (transactionId) => ({ success: true, transactionId })),
+ refund: vi.fn(async (transactionId) => ({ success: true, transactionId })),
+ };
+ let moduleRef: TestingModule;
+ let instant: Fixture;
+ let optedIn: Fixture;
+
+ beforeAll(async () => {
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', 'true');
+ vi.stubEnv('AUTH_ENABLED', 'false');
+ vi.stubEnv('NODE_ENV', 'test');
+ vi.stubEnv('PAYMENT_GATEWAY', 'mock');
+ vi.stubEnv('STRIPE_MODE', 'mock');
+ vi.stubEnv('DATABASE_URL', scratchDatabaseUrl);
+ vi.stubEnv('REDIS_URL', process.env['REDIS_URL'] ?? 'redis://localhost:6379');
+
+ runDatabaseUtility('createdb', databaseName);
+ execFileBounded('node', ['packages/database/dist/run-migrations.js'], {
+ cwd: join(__dirname, '../../../../..'),
+ env: { ...process.env, DATABASE_URL: scratchDatabaseUrl },
+ label: 'database schema installation',
+ secret: decodeURIComponent(new URL(scratchDatabaseUrl).password),
+ timeout: PUSH_SCHEMA_TIMEOUT_MS,
+ });
+ execFileBounded('pnpm', ['--filter', '@telivityhaip/booking-requests', 'run', 'db:migrate'], {
+ cwd: join(__dirname, '../../../../..'),
+ env: { ...process.env, DATABASE_URL: scratchDatabaseUrl },
+ label: 'booking-requests schema installation',
+ secret: decodeURIComponent(new URL(scratchDatabaseUrl).password),
+ timeout: PUSH_SCHEMA_TIMEOUT_MS,
+ });
+
+ instant = await createFixture('instant-default', 60);
+ optedIn = await createFixture('request-opt-in', 90, 'request');
+
+ const { preloadBookingRequestsModules } = await import('../../booking-requests.bootstrap.js');
+ await preloadBookingRequestsModules();
+ const { AppModule } = await import('../../app.module.js');
+ moduleRef = await Test.createTestingModule({ imports: [AppModule] })
+ .overrideProvider(DRIZZLE)
+ .useValue(db)
+ .overrideProvider(PAYMENT_GATEWAY)
+ .useValue(gateway)
+ .overrideProvider(EmailService)
+ .useValue(emailService)
+ .overrideProvider(WebhookService)
+ .useValue(webhookService)
+ .compile();
+ await moduleRef.init();
+ }, 120_000);
+
+ afterAll(async () => {
+ const failures: unknown[] = [];
+ try {
+ await moduleRef?.close();
+ } catch (error) {
+ failures.push(error);
+ }
+ try {
+ await client.end({ timeout: 2 });
+ } catch (error) {
+ failures.push(error);
+ }
+ try {
+ runDatabaseUtility('dropdb', databaseName);
+ } catch (error) {
+ failures.push(error);
+ }
+ vi.unstubAllEnvs();
+ if (failures.length > 0) {
+ throw new AggregateError(failures, 'default-flow release-gate teardown failed');
+ }
+ });
+
+ it('keeps final-schema database-default instant booking and shared financial behavior', async () => {
+ const bookingEngine = moduleRef.get(BookingEngineService);
+ const config = moduleRef.get(BookingEngineConfigService);
+ const stay = {
+ roomTypeId: instant.roomTypeId,
+ ratePlanId: instant.ratePlanId,
+ checkIn: instant.arrivalDate,
+ checkOut: instant.departureDate,
+ adults: 2,
+ children: 0,
+ };
+
+ expect(await config.getPublicConfig(instant.propertyId)).toMatchObject({
+ propertyId: instant.propertyId,
+ bookingMode: 'instant',
+ paymentMethodCollection: 'disabled',
+ depositPolicy: { type: 'first_night', refundable: true },
+ });
+ expect(await bookingEngine.quote(instant.propertyId, stay)).toMatchObject({
+ currencyCode: 'USD',
+ nights: 2,
+ grandTotal: '200.00',
+ depositDue: '100.00',
+ });
+ const booking = await bookingEngine.book(instant.propertyId, {
+ ...stay,
+ guestFirstName: 'Instant',
+ guestLastName: 'Default',
+ guestEmail: 'instant-default@example.com',
+ paymentToken: 'tok_default_flow',
+ cardLastFour: '4242',
+ cardBrand: 'visa',
+ });
+ expect(booking).toMatchObject({
+ success: true,
+ status: 'pending',
+ grandTotal: '200.00',
+ deposit: { amount: '100.00', status: 'held' },
+ });
+
+ const [parent] = await db
+ .select()
+ .from(payments)
+ .where(and(
+ eq(payments.id, booking.deposit!.paymentId),
+ eq(payments.propertyId, instant.propertyId),
+ ));
+ const [folio] = await db
+ .select()
+ .from(folios)
+ .where(and(
+ eq(folios.reservationId, booking.reservationId),
+ eq(folios.propertyId, instant.propertyId),
+ ));
+ const instantRequests = await db
+ .select({ id: bookingRequests.id })
+ .from(bookingRequests)
+ .where(eq(bookingRequests.propertyId, instant.propertyId));
+ const instantDeposits = await db
+ .select()
+ .from(depositLedgerEntries)
+ .where(and(
+ eq(depositLedgerEntries.paymentId, parent!.id),
+ eq(depositLedgerEntries.propertyId, instant.propertyId),
+ ));
+ expect(parent).toMatchObject({
+ bookingRequestId: null,
+ folioId: folio!.id,
+ amount: '100.00',
+ currencyCode: 'USD',
+ status: 'authorized',
+ gatewayProvider: 'stripe',
+ });
+ expect(instantDeposits).toEqual([
+ expect.objectContaining({ paymentId: parent!.id, amount: '100.00', status: 'held' }),
+ ]);
+ expect(instantRequests).toEqual([]);
+
+ await moduleRef.get(FolioService).postCharge(folio!.id, {
+ propertyId: instant.propertyId,
+ type: 'room',
+ description: 'Two-night default-flow stay',
+ amount: '200.00',
+ currencyCode: 'USD',
+ taxAmount: '0.00',
+ serviceDate: instant.arrivalDate,
+ skipTaxCalculation: true,
+ });
+ await moduleRef.get(PaymentService).capturePayment(parent!.id, instant.propertyId);
+ await expectFolioTotals(folio!.id, instant.propertyId, '200.00', '100.00', '100.00');
+
+ const stripeWebhook = moduleRef.get(StripeWebhookController);
+ const stripeDriver = stripeWebhook as unknown as StripeWebhookDriver;
+ const charge = {
+ id: `ch_${randomUUID()}`,
+ payment_intent: parent!.gatewayTransactionId,
+ currency: 'usd',
+ refunds: { data: [] },
+ };
+ await expectStripeWebhookAccepted(stripeWebhook, stripeDriver, {
+ id: `evt_partial_${randomUUID()}`,
+ type: 'charge.refunded',
+ data: { object: { ...charge, amount_refunded: 2500 } },
+ });
+ await expectFolioTotals(folio!.id, instant.propertyId, '200.00', '75.00', '125.00');
+
+ await expectStripeWebhookAccepted(stripeWebhook, stripeDriver, {
+ id: `evt_full_${randomUUID()}`,
+ type: 'charge.refunded',
+ data: { object: { ...charge, amount_refunded: 10000 } },
+ });
+ const refundChildren = await db
+ .select()
+ .from(payments)
+ .where(and(
+ eq(payments.propertyId, instant.propertyId),
+ eq(payments.originalPaymentId, parent!.id),
+ ));
+ expect(refundChildren.map((row) => row.amount).sort()).toEqual(['-25.00', '-75.00']);
+ await expectFolioTotals(folio!.id, instant.propertyId, '200.00', '0.00', '200.00');
+
+ const beforeUnrelated = await financialWriteSnapshot();
+ const beforeWebhookCalls = {
+ emit: webhookService.emit.mock.calls.length,
+ dispatchPersisted: webhookService.dispatchPersisted.mock.calls.length,
+ };
+ for (const event of [
+ {
+ id: `evt_external_payment_${randomUUID()}`,
+ type: 'payment_intent.succeeded',
+ data: {
+ object: {
+ id: `pi_external_${randomUUID()}`,
+ amount: 1000,
+ amount_received: 1000,
+ currency: 'usd',
+ customer: null,
+ payment_method: null,
+ metadata: {},
+ },
+ },
+ },
+ {
+ id: `evt_external_refund_${randomUUID()}`,
+ type: 'refund.updated',
+ data: {
+ object: {
+ id: `re_external_${randomUUID()}`,
+ status: 'succeeded',
+ amount: 1000,
+ currency: 'usd',
+ metadata: {},
+ },
+ },
+ },
+ ]) {
+ await expectStripeWebhookAccepted(stripeWebhook, stripeDriver, event);
+ }
+ expect(await financialWriteSnapshot()).toEqual(beforeUnrelated);
+ expect({
+ emit: webhookService.emit.mock.calls.length,
+ dispatchPersisted: webhookService.dispatchPersisted.mock.calls.length,
+ }).toEqual(beforeWebhookCalls);
+ }, 120_000);
+
+ it('activates request persistence only for a property explicitly configured for request mode', async () => {
+ const bookingEngine = moduleRef.get(BookingEngineService);
+ const config = moduleRef.get(BookingEngineConfigService);
+ const bookingRequest = moduleRef.get(BookingRequestService);
+ const stay = {
+ roomTypeId: optedIn.roomTypeId,
+ ratePlanId: optedIn.ratePlanId,
+ checkIn: optedIn.arrivalDate,
+ checkOut: optedIn.departureDate,
+ adults: 2,
+ children: 0,
+ };
+
+ expect(await config.getPublicConfig(optedIn.propertyId)).toMatchObject({
+ propertyId: optedIn.propertyId,
+ bookingMode: 'request',
+ paymentMethodCollection: 'disabled',
+ });
+ await expect(bookingEngine.book(optedIn.propertyId, {
+ ...stay,
+ guestFirstName: 'Blocked',
+ guestLastName: 'Instant',
+ guestEmail: 'blocked-instant@example.com',
+ })).rejects.toThrow(/staff review/i);
+
+ const submitted = await bookingRequest.submit(optedIn.propertyId, {
+ idempotencyKey: `request-opt-in-${randomUUID()}`,
+ ...stay,
+ guestFirstName: 'Request',
+ guestLastName: 'Only',
+ guestEmail: 'request-only@example.com',
+ applicationAnswers: {},
+ });
+ expect(submitted).toMatchObject({ status: 'pending' });
+
+ const [
+ requestRow,
+ reservationRows,
+ paymentRows,
+ consequenceRows,
+ emailRows,
+ ] = await Promise.all([
+ db.select().from(bookingRequests).where(and(
+ eq(bookingRequests.id, submitted.requestId),
+ eq(bookingRequests.propertyId, optedIn.propertyId),
+ )),
+ db.select({ id: reservations.id }).from(reservations)
+ .where(eq(reservations.propertyId, optedIn.propertyId)),
+ db.select({ id: payments.id }).from(payments)
+ .where(eq(payments.propertyId, optedIn.propertyId)),
+ db.select().from(bookingRequestConsequences)
+ .where(eq(bookingRequestConsequences.propertyId, optedIn.propertyId)),
+ db.select().from(bookingRequestEmailDeliveries)
+ .where(eq(bookingRequestEmailDeliveries.propertyId, optedIn.propertyId)),
+ ]);
+ expect(requestRow).toEqual([
+ expect.objectContaining({
+ status: 'pending',
+ submittedTotal: '200.00',
+ acceptedReservationId: null,
+ acceptedFolioId: null,
+ }),
+ ]);
+ expect(reservationRows).toEqual([]);
+ expect(paymentRows).toEqual([]);
+ expect(consequenceRows).toEqual([
+ expect.objectContaining({
+ bookingRequestId: submitted.requestId,
+ kind: 'created_event',
+ status: 'completed',
+ attempts: 1,
+ }),
+ ]);
+ expect(emailRows).toEqual([
+ expect.objectContaining({
+ bookingRequestId: submitted.requestId,
+ kind: 'receipt',
+ status: 'sent',
+ attempts: 1,
+ automaticAttempts: 1,
+ providerMessageId: 'task8-receipt-message',
+ }),
+ ]);
+ expect(emailService.send).toHaveBeenCalledOnce();
+ }, 120_000);
+
+ async function createFixture(
+ label: string,
+ arrivalOffset: number,
+ bookingMode?: 'request',
+ ): Promise {
+ const propertyId = randomUUID();
+ const roomTypeId = randomUUID();
+ const ratePlanId = randomUUID();
+ await db.insert(properties).values({
+ id: propertyId,
+ name: `Task 8 ${label}`,
+ code: `T8${randomBytes(5).toString('hex').toUpperCase()}`,
+ countryCode: 'US',
+ timezone: 'UTC',
+ currencyCode: 'USD',
+ totalRooms: 1,
+ });
+ await db.insert(roomTypes).values({
+ id: roomTypeId,
+ propertyId,
+ name: 'Default Flow Room',
+ code: 'DEFAULT',
+ maxOccupancy: 2,
+ defaultOccupancy: 2,
+ });
+ await db.insert(rooms).values({
+ propertyId,
+ roomTypeId,
+ number: `T8-${randomBytes(4).toString('hex')}`,
+ });
+ await db.insert(ratePlans).values({
+ id: ratePlanId,
+ propertyId,
+ roomTypeId,
+ name: 'Default Flow Rate',
+ code: 'DEFAULT',
+ type: 'bar',
+ baseAmount: '100.00',
+ currencyCode: 'USD',
+ });
+ await db.insert(bookingEngineConfig).values({
+ propertyId,
+ isEnabled: true,
+ sellableRoomTypeIds: [roomTypeId],
+ sellableRatePlanIds: [ratePlanId],
+ });
+ // `bookingMode` is a thin request-mode config hook core reads directly
+ // (see `packages/database/src/schema/booking-engine.ts`), not a
+ // booking-requests-package-only column.
+ if (bookingMode) {
+ await db
+ .update(bookingEngineConfig)
+ .set({ bookingMode })
+ .where(eq(bookingEngineConfig.propertyId, propertyId));
+ }
+ return {
+ propertyId,
+ roomTypeId,
+ ratePlanId,
+ arrivalDate: dateFromNow(arrivalOffset),
+ departureDate: dateFromNow(arrivalOffset + 2),
+ };
+ }
+
+ async function expectFolioTotals(
+ folioId: string,
+ propertyId: string,
+ totalCharges: string,
+ totalPayments: string,
+ balance: string,
+ ): Promise {
+ const [folio] = await db
+ .select()
+ .from(folios)
+ .where(and(eq(folios.id, folioId), eq(folios.propertyId, propertyId)));
+ expect(folio).toMatchObject({ totalCharges, totalPayments, balance });
+ }
+
+ async function financialWriteSnapshot() {
+ const [
+ paymentRows,
+ chargeRows,
+ folioRows,
+ depositRows,
+ reservationRows,
+ requestRows,
+ installmentRows,
+ allocationRows,
+ resolutionRows,
+ amendmentRows,
+ consequenceRows,
+ emailRows,
+ webhookRows,
+ auditRows,
+ ] = await Promise.all([
+ // Service queries remain tenant-scoped. This inventory is intentionally
+ // global because the scratch database is isolated: it must catch a broken
+ // unrelated-event path that writes under either fixture or no tenant.
+ db.select().from(payments),
+ db.select().from(charges),
+ db.select().from(folios),
+ db.select().from(depositLedgerEntries),
+ db.select().from(reservations),
+ db.select().from(bookingRequests),
+ db.select().from(bookingRequestInstallments),
+ db.select().from(bookingRequestPaymentAllocations),
+ db.select().from(bookingRequestPaymentResolutions),
+ db.select().from(bookingRequestStayAmendments),
+ db.select().from(bookingRequestConsequences),
+ db.select().from(bookingRequestEmailDeliveries),
+ db.select().from(webhookDeliveries),
+ db.select().from(auditLogs),
+ ]);
+ return {
+ paymentRows,
+ chargeRows,
+ folioRows,
+ depositRows,
+ reservationRows,
+ requestRows,
+ installmentRows,
+ allocationRows,
+ resolutionRows,
+ amendmentRows,
+ consequenceRows,
+ emailRows,
+ webhookRows,
+ auditRows,
+ };
+ }
+});
+
+async function expectStripeWebhookAccepted(
+ controller: StripeWebhookController,
+ driver: StripeWebhookDriver,
+ event: Record,
+) {
+ driver.stripe = { webhooks: { constructEvent: () => event } };
+ driver.webhookSecret = 'whsec_task8';
+ vi.stubEnv('STRIPE_MODE', 'live');
+ const response = {
+ status: vi.fn().mockReturnThis(),
+ json: vi.fn().mockReturnThis(),
+ };
+ await controller.handleWebhook({
+ headers: { 'stripe-signature': 'task8-signature' },
+ body: Buffer.from('{}'),
+ }, response);
+ expect(response.status).toHaveBeenCalledWith(200);
+ expect(response.json).toHaveBeenCalledWith({ received: true });
+}
diff --git a/apps/api/src/modules/booking-request/booking-request-flag-off-instant-booking.regression.spec.ts b/apps/api/src/modules/booking-request/booking-request-flag-off-instant-booking.regression.spec.ts
new file mode 100644
index 00000000..8ca8edb4
--- /dev/null
+++ b/apps/api/src/modules/booking-request/booking-request-flag-off-instant-booking.regression.spec.ts
@@ -0,0 +1,386 @@
+/**
+ * Flag-OFF default-install release gate.
+ *
+ * Verifies the deployment default (HAIP_BOOKING_REQUESTS unset/false) still
+ * works end to end when the optional booking-requests package is never
+ * touched: only core migrations run, AppModule boots without the
+ * booking-requests Nest module, `booking_requests` and friends do not exist
+ * in the schema, and the pre-existing instant-booking + deposit/refund path
+ * (the only path a default install has) keeps working. Mirrors the instant
+ * half of booking-request-default-flow-regression.spec.ts, but that spec
+ * always sets HAIP_BOOKING_REQUESTS=true — it never exercises what most
+ * production installs actually run.
+ */
+import { randomBytes, randomUUID } from 'node:crypto';
+import { join } from 'node:path';
+import { Test, type TestingModule } from '@nestjs/testing';
+import { and, eq, sql } from 'drizzle-orm';
+import { drizzle } from 'drizzle-orm/postgres-js';
+import postgres from 'postgres';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import * as schema from '@telivityhaip/database';
+import {
+ bookingEngineConfig,
+ depositLedgerEntries,
+ folios,
+ payments,
+ properties,
+ ratePlans,
+ rooms,
+ roomTypes,
+} from '@telivityhaip/database';
+import { DRIZZLE } from '../../database/database.module';
+import { EmailService } from '../agent/guest-comms/email.service';
+import { BookingEngineConfigService } from '../booking-engine/booking-engine-config.service';
+import { BookingEngineService } from '../booking-engine/booking-engine.service';
+import { FolioService } from '../folio/folio.service';
+import { PAYMENT_GATEWAY } from '../payment/interfaces/payment-gateway.interface';
+import type { PaymentGateway } from '../payment/interfaces/payment-gateway.interface';
+import { PaymentService } from '../payment/payment.service';
+import { StripeWebhookController } from '../payment/stripe-webhook.controller';
+import { WebhookService } from '../webhook/webhook.service';
+import { createRegressionDatabaseHelpers } from './regression-database-utils.js';
+
+const baseDatabaseUrl = process.env['DATABASE_URL'];
+const describeDatabase = baseDatabaseUrl ? describe : describe.skip;
+const connectionTemplate = baseDatabaseUrl
+ ?? 'postgresql://unavailable:unavailable@127.0.0.1:1/haip';
+const { databaseUrlFor, execFileBounded, runDatabaseUtility } =
+ createRegressionDatabaseHelpers(connectionTemplate);
+const CORE_MIGRATION_TIMEOUT_MS = 60_000;
+
+type Fixture = {
+ propertyId: string;
+ roomTypeId: string;
+ ratePlanId: string;
+ arrivalDate: string;
+ departureDate: string;
+};
+
+type StripeWebhookDriver = {
+ stripe: { webhooks: { constructEvent: () => Record } };
+ webhookSecret: string;
+};
+
+function dateFromNow(days: number): string {
+ const date = new Date();
+ date.setUTCDate(date.getUTCDate() + days);
+ return date.toISOString().slice(0, 10);
+}
+
+describeDatabase('Booking engine flag-OFF default-install release gate', () => {
+ const databaseName = `flagoff_default_install_${randomBytes(10).toString('hex')}`;
+ const scratchDatabaseUrl = databaseUrlFor(databaseName);
+ const client = postgres(scratchDatabaseUrl, { max: 8 });
+ const db = drizzle(client, { schema });
+ const webhookService = {
+ emit: vi.fn(async () => undefined),
+ dispatchPersisted: vi.fn(async () => undefined),
+ };
+ const emailService = {
+ isConfigured: vi.fn(() => true),
+ send: vi.fn(async () => ({
+ sent: true,
+ provider: 'flag-off-memory',
+ messageId: 'flag-off-receipt-message',
+ })),
+ };
+ const gateway: PaymentGateway = {
+ authorize: vi.fn(async () => ({ success: true, transactionId: `pi_${randomUUID()}` })),
+ capture: vi.fn(async (transactionId) => ({ success: true, transactionId })),
+ void: vi.fn(async (transactionId) => ({ success: true, transactionId })),
+ refund: vi.fn(async (transactionId) => ({ success: true, transactionId })),
+ };
+ let moduleRef: TestingModule;
+ let instant: Fixture;
+
+ beforeAll(async () => {
+ // The point of this suite: HAIP_BOOKING_REQUESTS is deliberately left
+ // unset, matching a default install/clone that never opted into the
+ // optional package.
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', '');
+ vi.stubEnv('AUTH_ENABLED', 'false');
+ vi.stubEnv('NODE_ENV', 'test');
+ vi.stubEnv('PAYMENT_GATEWAY', 'mock');
+ vi.stubEnv('STRIPE_MODE', 'mock');
+ vi.stubEnv('DATABASE_URL', scratchDatabaseUrl);
+ vi.stubEnv('REDIS_URL', process.env['REDIS_URL'] ?? 'redis://localhost:6379');
+
+ runDatabaseUtility('createdb', databaseName);
+ execFileBounded('node', ['packages/database/dist/run-migrations.js'], {
+ cwd: join(__dirname, '../../../../..'),
+ env: { ...process.env, DATABASE_URL: scratchDatabaseUrl },
+ label: 'core database schema installation',
+ secret: decodeURIComponent(new URL(scratchDatabaseUrl).password),
+ timeout: CORE_MIGRATION_TIMEOUT_MS,
+ });
+ // Deliberately no `pnpm --filter @telivityhaip/booking-requests db:migrate`
+ // here — a default install never runs it.
+
+ instant = await createFixture('flag-off-instant', 60);
+
+ const { preloadBookingRequestsModules } = await import('../../booking-requests.bootstrap.js');
+ await preloadBookingRequestsModules();
+ const { AppModule } = await import('../../app.module.js');
+ moduleRef = await Test.createTestingModule({ imports: [AppModule] })
+ .overrideProvider(DRIZZLE)
+ .useValue(db)
+ .overrideProvider(PAYMENT_GATEWAY)
+ .useValue(gateway)
+ .overrideProvider(EmailService)
+ .useValue(emailService)
+ .overrideProvider(WebhookService)
+ .useValue(webhookService)
+ .compile();
+ await moduleRef.init();
+ }, 120_000);
+
+ afterAll(async () => {
+ const failures: unknown[] = [];
+ try {
+ await moduleRef?.close();
+ } catch (error) {
+ failures.push(error);
+ }
+ try {
+ await client.end({ timeout: 2 });
+ } catch (error) {
+ failures.push(error);
+ }
+ try {
+ runDatabaseUtility('dropdb', databaseName);
+ } catch (error) {
+ failures.push(error);
+ }
+ vi.unstubAllEnvs();
+ if (failures.length > 0) {
+ throw new AggregateError(failures, 'flag-off release-gate teardown failed');
+ }
+ });
+
+ it('never creates the optional booking-requests tables when the flag is off', async () => {
+ const [row] = await db.execute<{ exists: boolean }>(sql`
+ SELECT EXISTS (
+ SELECT 1 FROM information_schema.tables WHERE table_name = 'booking_requests'
+ ) AS exists
+ `);
+ expect(row!.exists).toBe(false);
+ });
+
+ it('boots AppModule without a BookingRequestService provider registered', async () => {
+ const { BookingRequestService } = await import('@telivityhaip/booking-requests');
+ expect(() => moduleRef.get(BookingRequestService)).toThrow();
+ });
+
+ it('rejects switching bookingMode to request when the deployment flag is off', async () => {
+ const config = moduleRef.get(BookingEngineConfigService);
+ await expect(config.updateConfig(
+ instant.propertyId,
+ { bookingMode: 'request' },
+ undefined,
+ { userId: null, userEmail: null, ipAddress: null },
+ )).rejects.toThrow(/HAIP_BOOKING_REQUESTS/);
+ });
+
+ it('keeps instant booking, deposit capture, and partial/full refunds working with only core migrations applied', async () => {
+ const bookingEngine = moduleRef.get(BookingEngineService);
+ const config = moduleRef.get(BookingEngineConfigService);
+ const stay = {
+ roomTypeId: instant.roomTypeId,
+ ratePlanId: instant.ratePlanId,
+ checkIn: instant.arrivalDate,
+ checkOut: instant.departureDate,
+ adults: 2,
+ children: 0,
+ };
+
+ expect(await config.getPublicConfig(instant.propertyId)).toMatchObject({
+ propertyId: instant.propertyId,
+ bookingMode: 'instant',
+ paymentMethodCollection: 'disabled',
+ depositPolicy: { type: 'first_night', refundable: true },
+ });
+ expect(await bookingEngine.quote(instant.propertyId, stay)).toMatchObject({
+ currencyCode: 'USD',
+ nights: 2,
+ grandTotal: '200.00',
+ depositDue: '100.00',
+ });
+ const booking = await bookingEngine.book(instant.propertyId, {
+ ...stay,
+ guestFirstName: 'FlagOff',
+ guestLastName: 'Default',
+ guestEmail: 'flag-off-default@example.com',
+ paymentToken: 'tok_flag_off',
+ cardLastFour: '4242',
+ cardBrand: 'visa',
+ });
+ expect(booking).toMatchObject({
+ success: true,
+ status: 'pending',
+ grandTotal: '200.00',
+ deposit: { amount: '100.00', status: 'held' },
+ });
+
+ const [parent] = await db
+ .select()
+ .from(payments)
+ .where(and(
+ eq(payments.id, booking.deposit!.paymentId),
+ eq(payments.propertyId, instant.propertyId),
+ ));
+ const [folio] = await db
+ .select()
+ .from(folios)
+ .where(and(
+ eq(folios.reservationId, booking.reservationId),
+ eq(folios.propertyId, instant.propertyId),
+ ));
+ const instantDeposits = await db
+ .select()
+ .from(depositLedgerEntries)
+ .where(and(
+ eq(depositLedgerEntries.paymentId, parent!.id),
+ eq(depositLedgerEntries.propertyId, instant.propertyId),
+ ));
+ expect(parent).toMatchObject({
+ bookingRequestId: null,
+ folioId: folio!.id,
+ amount: '100.00',
+ currencyCode: 'USD',
+ status: 'authorized',
+ gatewayProvider: 'stripe',
+ });
+ expect(instantDeposits).toEqual([
+ expect.objectContaining({ paymentId: parent!.id, amount: '100.00', status: 'held' }),
+ ]);
+
+ await moduleRef.get(FolioService).postCharge(folio!.id, {
+ propertyId: instant.propertyId,
+ type: 'room',
+ description: 'Two-night flag-off stay',
+ amount: '200.00',
+ currencyCode: 'USD',
+ taxAmount: '0.00',
+ serviceDate: instant.arrivalDate,
+ skipTaxCalculation: true,
+ });
+ await moduleRef.get(PaymentService).capturePayment(parent!.id, instant.propertyId);
+ await expectFolioTotals(folio!.id, instant.propertyId, '200.00', '100.00', '100.00');
+
+ const stripeWebhook = moduleRef.get(StripeWebhookController);
+ const stripeDriver = stripeWebhook as unknown as StripeWebhookDriver;
+ const charge = {
+ id: `ch_${randomUUID()}`,
+ payment_intent: parent!.gatewayTransactionId,
+ currency: 'usd',
+ refunds: { data: [] },
+ };
+ await expectStripeWebhookAccepted(stripeWebhook, stripeDriver, {
+ id: `evt_partial_${randomUUID()}`,
+ type: 'charge.refunded',
+ data: { object: { ...charge, amount_refunded: 2500 } },
+ });
+ await expectFolioTotals(folio!.id, instant.propertyId, '200.00', '75.00', '125.00');
+
+ await expectStripeWebhookAccepted(stripeWebhook, stripeDriver, {
+ id: `evt_full_${randomUUID()}`,
+ type: 'charge.refunded',
+ data: { object: { ...charge, amount_refunded: 10000 } },
+ });
+ const refundChildren = await db
+ .select()
+ .from(payments)
+ .where(and(
+ eq(payments.propertyId, instant.propertyId),
+ eq(payments.originalPaymentId, parent!.id),
+ ));
+ expect(refundChildren.map((row) => row.amount).sort()).toEqual(['-25.00', '-75.00']);
+ await expectFolioTotals(folio!.id, instant.propertyId, '200.00', '0.00', '200.00');
+ }, 120_000);
+
+ async function createFixture(label: string, arrivalOffset: number): Promise {
+ const propertyId = randomUUID();
+ const roomTypeId = randomUUID();
+ const ratePlanId = randomUUID();
+ await db.insert(properties).values({
+ id: propertyId,
+ name: `Flag Off ${label}`,
+ code: `FO${randomBytes(5).toString('hex').toUpperCase()}`,
+ countryCode: 'US',
+ timezone: 'UTC',
+ currencyCode: 'USD',
+ totalRooms: 1,
+ });
+ await db.insert(roomTypes).values({
+ id: roomTypeId,
+ propertyId,
+ name: 'Flag Off Room',
+ code: 'DEFAULT',
+ maxOccupancy: 2,
+ defaultOccupancy: 2,
+ });
+ await db.insert(rooms).values({
+ propertyId,
+ roomTypeId,
+ number: `FO-${randomBytes(4).toString('hex')}`,
+ });
+ await db.insert(ratePlans).values({
+ id: ratePlanId,
+ propertyId,
+ roomTypeId,
+ name: 'Flag Off Rate',
+ code: 'DEFAULT',
+ type: 'bar',
+ baseAmount: '100.00',
+ currencyCode: 'USD',
+ });
+ await db.insert(bookingEngineConfig).values({
+ propertyId,
+ isEnabled: true,
+ sellableRoomTypeIds: [roomTypeId],
+ sellableRatePlanIds: [ratePlanId],
+ });
+ return {
+ propertyId,
+ roomTypeId,
+ ratePlanId,
+ arrivalDate: dateFromNow(arrivalOffset),
+ departureDate: dateFromNow(arrivalOffset + 2),
+ };
+ }
+
+ async function expectFolioTotals(
+ folioId: string,
+ propertyId: string,
+ totalCharges: string,
+ totalPayments: string,
+ balance: string,
+ ): Promise {
+ const [folio] = await db
+ .select()
+ .from(folios)
+ .where(and(eq(folios.id, folioId), eq(folios.propertyId, propertyId)));
+ expect(folio).toMatchObject({ totalCharges, totalPayments, balance });
+ }
+});
+
+async function expectStripeWebhookAccepted(
+ controller: StripeWebhookController,
+ driver: StripeWebhookDriver,
+ event: Record,
+) {
+ driver.stripe = { webhooks: { constructEvent: () => event } };
+ driver.webhookSecret = 'whsec_flag_off';
+ vi.stubEnv('STRIPE_MODE', 'live');
+ const response = {
+ status: vi.fn().mockReturnThis(),
+ json: vi.fn().mockReturnThis(),
+ };
+ await controller.handleWebhook({
+ headers: { 'stripe-signature': 'flag-off-signature' },
+ body: Buffer.from('{}'),
+ }, response);
+ expect(response.status).toHaveBeenCalledWith(200);
+ expect(response.json).toHaveBeenCalledWith({ received: true });
+}
diff --git a/apps/api/src/modules/booking-request/booking-request-service-transaction-seams.spec.ts b/apps/api/src/modules/booking-request/booking-request-service-transaction-seams.spec.ts
new file mode 100644
index 00000000..df3d5fbc
--- /dev/null
+++ b/apps/api/src/modules/booking-request/booking-request-service-transaction-seams.spec.ts
@@ -0,0 +1,296 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ bookings,
+ folios,
+ guests,
+ ratePlanComponents,
+ reservationGuests,
+ reservationServices,
+ reservations,
+ roomTypes,
+ services,
+} from '@telivityhaip/database';
+import { GuestService } from '../guest/guest.service';
+import { FolioService } from '../folio/folio.service';
+import { ReservationService } from '../reservation/reservation.service';
+import { AncillaryService } from '../ancillary/ancillary.service';
+
+const PROPERTY_ID = 'aaaaaaaa-0000-4000-a000-000000000001';
+const ROOM_TYPE_ID = 'cccccccc-0000-4000-a000-000000000001';
+const RATE_PLAN_ID = 'dddddddd-0000-4000-a000-000000000001';
+const RESERVATION_ID = 'eeeeeeee-0000-4000-a000-000000000001';
+const FOLIO_ID = 'ffffffff-0000-4000-a000-000000000001';
+const GUEST_ID = '11111111-0000-4000-a000-000000000001';
+
+/**
+ * `@telivityhaip/booking-requests`'s BookingRequestService relies on every
+ * core creation-path service (Guest/Folio/Reservation/Ancillary) honoring an
+ * explicit caller transaction instead of opening its own — this is the
+ * contract the package's `ReservationServicePort` / `FolioServicePort` /
+ * `GuestServicePort` / `AncillaryServicePort` `useExisting` bindings depend
+ * on. These are core-service tests (not booking-request package tests) kept
+ * in apps/api because they instantiate the real core classes directly.
+ */
+describe('canonical creation transaction seams', () => {
+ it('GuestService.create uses the caller transaction', async () => {
+ const mainDb = {
+ insert: vi.fn(() => {
+ throw new Error('main database used');
+ }),
+ };
+ const tx = {
+ insert: vi.fn((table: unknown) => {
+ expect(table).toBe(guests);
+ return {
+ values: vi.fn(() => ({
+ returning: vi.fn(async () => [{ id: GUEST_ID }]),
+ })),
+ };
+ }),
+ };
+ const service = new GuestService(mainDb as any);
+
+ const result = await (service.create as any)({
+ firstName: 'Ada',
+ lastName: 'Lovelace',
+ email: 'ada@example.com',
+ }, tx);
+
+ expect(result).toEqual({ id: GUEST_ID });
+ expect(mainDb.insert).not.toHaveBeenCalled();
+ });
+
+ it('FolioService.createAutoFolio uses the caller transaction and emits no pre-commit webhook', async () => {
+ const mainDb = {
+ select: vi.fn(() => {
+ throw new Error('main database used');
+ }),
+ insert: vi.fn(() => {
+ throw new Error('main database used');
+ }),
+ };
+ const webhook = { emit: vi.fn() };
+ const tx = {
+ select: vi.fn(() => {
+ let table: unknown;
+ const chain: Record & PromiseLike = {
+ from: vi.fn((value: unknown) => {
+ table = value;
+ return chain;
+ }),
+ where: vi.fn(() => chain),
+ for: vi.fn(() => Promise.resolve(
+ table === roomTypes ? [{ id: ROOM_TYPE_ID }] : [],
+ )),
+ then: (resolve, reject) => Promise.resolve(
+ table === folios ? [{ maxNumber: null }] : [{ id: 'exists' }],
+ ).then(resolve, reject),
+ };
+ return chain;
+ }),
+ insert: vi.fn((table: unknown) => {
+ expect(table).toBe(folios);
+ return {
+ values: vi.fn((values: Record) => ({
+ returning: vi.fn(async () => [{ id: FOLIO_ID, ...values }]),
+ })),
+ };
+ }),
+ };
+ const service = new FolioService(mainDb as any, webhook as any, {} as any);
+
+ const result = await (service.createAutoFolio as any)({
+ id: RESERVATION_ID,
+ propertyId: PROPERTY_ID,
+ bookingId: '33333333-0000-4000-a000-000000000001',
+ guestId: GUEST_ID,
+ currencyCode: 'EUR',
+ }, tx);
+
+ expect(result.id).toBe(FOLIO_ID);
+ expect(mainDb.insert).not.toHaveBeenCalled();
+ expect(webhook.emit).not.toHaveBeenCalled();
+ });
+
+ it('ReservationService.create performs every lookup and insert in the caller transaction', async () => {
+ const mainDb = {
+ select: vi.fn(() => {
+ throw new Error('main database used');
+ }),
+ transaction: vi.fn(() => {
+ throw new Error('nested transaction opened');
+ }),
+ };
+ const tx = {
+ select: vi.fn(() => {
+ let table: unknown;
+ const chain: Record & PromiseLike = {
+ from: vi.fn((value: unknown) => {
+ table = value;
+ return chain;
+ }),
+ where: vi.fn(() => chain),
+ for: vi.fn(() => Promise.resolve(
+ table === roomTypes ? [{ id: ROOM_TYPE_ID }] : [],
+ )),
+ then: (resolve, reject) => Promise.resolve(
+ table === guests
+ ? [{ id: GUEST_ID, isDnr: false }]
+ : [{ id: table === roomTypes ? ROOM_TYPE_ID : RATE_PLAN_ID }],
+ ).then(resolve, reject),
+ };
+ return chain;
+ }),
+ insert: vi.fn((_table: unknown) => ({
+ values: vi.fn((values: Record) => {
+ const row = _table === bookings
+ ? { id: '33333333-0000-4000-a000-000000000001', ...values }
+ : _table === reservations
+ ? { id: RESERVATION_ID, ...values }
+ : values;
+ return {
+ returning: vi.fn(async () => [row]),
+ then: (resolve: (value: unknown) => unknown) => Promise.resolve(undefined).then(resolve),
+ };
+ }),
+ })),
+ };
+ const availability = {
+ searchAvailability: vi.fn(async () => [{
+ roomTypeId: ROOM_TYPE_ID,
+ date: '2026-10-01',
+ available: 1,
+ }, {
+ roomTypeId: ROOM_TYPE_ID,
+ date: '2026-10-02',
+ available: 1,
+ }]),
+ };
+ const webhook = { emit: vi.fn() };
+ const ratePlan = { assertSellable: vi.fn(async () => undefined) };
+ const service = new ReservationService(
+ mainDb as any,
+ availability as any,
+ {} as any,
+ {} as any,
+ {} as any,
+ webhook as any,
+ {} as any,
+ {} as any,
+ {} as any,
+ ratePlan as any,
+ );
+
+ const result = await (service.create as any)({
+ propertyId: PROPERTY_ID,
+ guestId: GUEST_ID,
+ arrivalDate: '2026-10-01',
+ departureDate: '2026-10-03',
+ roomTypeId: ROOM_TYPE_ID,
+ ratePlanId: RATE_PLAN_ID,
+ totalAmount: '220.00',
+ currencyCode: 'EUR',
+ source: 'direct',
+ }, {}, tx);
+
+ expect(result.id).toBe(RESERVATION_ID);
+ expect(mainDb.transaction).not.toHaveBeenCalled();
+ expect(availability.searchAvailability).toHaveBeenCalledWith(
+ PROPERTY_ID,
+ '2026-10-01',
+ '2026-10-03',
+ ROOM_TYPE_ID,
+ tx,
+ );
+ expect(ratePlan.assertSellable).toHaveBeenCalledWith(
+ PROPERTY_ID,
+ RATE_PLAN_ID,
+ '2026-10-01',
+ '2026-10-03',
+ tx,
+ );
+ expect(tx.insert).toHaveBeenCalledWith(reservationGuests);
+ expect(webhook.emit).not.toHaveBeenCalled();
+ });
+
+ it('AncillaryService attach and package ensure use the caller transaction without emitting', async () => {
+ const mainDb = {
+ select: vi.fn(() => {
+ throw new Error('main database used');
+ }),
+ insert: vi.fn(() => {
+ throw new Error('main database used');
+ }),
+ };
+ const inserted: Array> = [];
+ const tx = {
+ select: vi.fn((selection?: Record) => {
+ let table: unknown;
+ const chain: Record & PromiseLike = {
+ from: vi.fn((value: unknown) => {
+ table = value;
+ return chain;
+ }),
+ where: vi.fn(() => chain),
+ then: (resolve, reject) => Promise.resolve(
+ table === reservations
+ ? [{ id: RESERVATION_ID, propertyId: PROPERTY_ID, ratePlanId: RATE_PLAN_ID }]
+ : table === services
+ ? [{
+ id: '77777777-0000-4000-a000-000000000001',
+ propertyId: PROPERTY_ID,
+ isActive: true,
+ price: '25.00',
+ currencyCode: 'EUR',
+ postingRule: 'once',
+ chargeType: 'fee',
+ name: 'Breakfast',
+ }]
+ : table === ratePlanComponents
+ ? [{
+ serviceId: '77777777-0000-4000-a000-000000000001',
+ quantity: 1,
+ includedInRate: true,
+ amountOverride: null,
+ }]
+ : table === reservationServices && selection
+ ? []
+ : [],
+ ).then(resolve, reject),
+ };
+ return chain;
+ }),
+ insert: vi.fn((_table: unknown) => ({
+ values: vi.fn((values: Record) => ({
+ returning: vi.fn(async () => {
+ const row = {
+ id: `88888888-0000-4000-a000-${String(inserted.length + 1).padStart(12, '0')}`,
+ ...values,
+ };
+ inserted.push(row);
+ return [row];
+ }),
+ })),
+ })),
+ };
+ const webhook = { emit: vi.fn() };
+ const service = new AncillaryService(mainDb as any, {} as any, webhook as any);
+
+ const selected = await (service.attachToReservation as any)(RESERVATION_ID, {
+ propertyId: PROPERTY_ID,
+ serviceId: '77777777-0000-4000-a000-000000000001',
+ sourceChannel: 'booking_engine',
+ }, tx);
+ const packaged = await (service.ensurePackageComponents as any)(
+ RESERVATION_ID,
+ PROPERTY_ID,
+ tx,
+ );
+
+ expect(selected.reservationId).toBe(RESERVATION_ID);
+ expect(packaged).toHaveLength(1);
+ expect(mainDb.select).not.toHaveBeenCalled();
+ expect(mainDb.insert).not.toHaveBeenCalled();
+ expect(webhook.emit).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/api/src/modules/booking-request/booking-request.e2e-spec.ts b/apps/api/src/modules/booking-request/booking-request.e2e-spec.ts
new file mode 100644
index 00000000..996854fd
--- /dev/null
+++ b/apps/api/src/modules/booking-request/booking-request.e2e-spec.ts
@@ -0,0 +1,984 @@
+import { randomUUID, createHash } from 'node:crypto';
+import { execFileSync } from 'node:child_process';
+import { join } from 'node:path';
+import { ValidationPipe, type INestApplication } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
+import { EventEmitter2 } from '@nestjs/event-emitter';
+import {
+ agentWebhookSubscriptions,
+ bookingEngineCredentials,
+ charges,
+ folios,
+ payments,
+ properties,
+ ratePlans,
+ reservations,
+ rooms,
+ roomTypes,
+ webhookDeliveries,
+} from '@telivityhaip/database';
+import {
+ bookingRequestAuditLogs as auditLogs,
+ bookingRequestConsequences,
+ bookingRequestEmailDeliveries,
+ bookingRequestInstallments,
+ bookingRequests,
+} from '@telivityhaip/booking-requests/schema';
+import { and, eq, inArray } from 'drizzle-orm';
+import { drizzle } from 'drizzle-orm/postgres-js';
+import postgres from 'postgres';
+import request from 'supertest';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import { AllExceptionsFilter } from '../../common/filters/all-exceptions.filter';
+import { DRIZZLE } from '../../database/database.module';
+import { EmailService } from '../agent/guest-comms/email.service';
+import {
+ SAVED_PAYMENT_METHOD_GATEWAY,
+ type SavedPaymentMethodGateway,
+} from '../payment/interfaces/saved-payment-method-gateway.interface';
+import { WebhookDeliveryService } from '../webhook/webhook-delivery.service';
+import { WebhookService, type WebhookPayload } from '../webhook/webhook.service';
+
+const databaseUrl = process.env['DATABASE_URL'];
+const describeDatabase = databaseUrl ? describe : describe.skip;
+const PRIVATE_ANSWER = 'E2E_PRIVATE_ANSWER_SENTINEL';
+const PRIVATE_CONSENT = 'E2E_PRIVATE_CONSENT_SENTINEL';
+const PRIVATE_SETUP_INTENT = 'seti_E2E_PRIVATE_TOKEN';
+const PRIVATE_PAYMENT_METHOD = 'pm_E2E_PRIVATE_TOKEN';
+const PRIVATE_CARD_BRAND = 'e2e_card_sentinel';
+const PRIVATE_CARD_LAST_FOUR = '6789';
+
+const savedPaymentMethodGateway: SavedPaymentMethodGateway = {
+ async createSetup() {
+ return {
+ setupIntentId: PRIVATE_SETUP_INTENT,
+ clientSecret: 'seti_E2E_PRIVATE_TOKEN_secret_E2E_PRIVATE_CLIENT_TOKEN',
+ customerId: 'cus_E2E_PRIVATE_TOKEN',
+ clientMode: 'stripe' as const,
+ };
+ },
+ async resolveSetup() {
+ return {
+ setupIntentId: PRIVATE_SETUP_INTENT,
+ customerId: 'cus_E2E_PRIVATE_TOKEN',
+ paymentMethodId: PRIVATE_PAYMENT_METHOD,
+ cardLastFour: PRIVATE_CARD_LAST_FOUR,
+ cardBrand: PRIVATE_CARD_BRAND,
+ };
+ },
+ async charge(input) {
+ return {
+ success: true,
+ transactionId: `pi_E2E_${input.paymentId}`,
+ requiresAction: false,
+ };
+ },
+};
+
+function dateFromNow(days: number): string {
+ const date = new Date();
+ date.setUTCDate(date.getUTCDate() + days);
+ return date.toISOString().slice(0, 10);
+}
+
+describeDatabase('Booking Request complete vertical slice', () => {
+ const propertyId = randomUUID();
+ const roomTypeId = randomUUID();
+ const ratePlanId = randomUUID();
+ const questionId = randomUUID();
+ const bookingKey = `pk_test_e2e_${randomUUID()}`;
+ const arrivalDate = dateFromNow(45);
+ const departureDate = dateFromNow(47);
+ const extendedDepartureDate = dateFromNow(48);
+ const instantArrivalDate = dateFromNow(75);
+ const instantDepartureDate = dateFromNow(77);
+ const applicationKey = `booking-request-e2e-${randomUUID()}`;
+ const webhookSubscriptionIds = [randomUUID(), randomUUID()];
+ const sentEmails: Array<{ to: string; subject: string; text: string }> = [];
+ let app: INestApplication;
+ let client: ReturnType;
+ let db: ReturnType;
+
+ beforeAll(async () => {
+ vi.stubEnv('HAIP_BOOKING_REQUESTS', 'true');
+ vi.stubEnv('AUTH_ENABLED', 'false');
+ vi.stubEnv('NODE_ENV', 'test');
+ vi.stubEnv('PAYMENT_GATEWAY', 'mock');
+ vi.stubEnv('STRIPE_MODE', 'mock');
+ if (!process.env['REDIS_URL']) {
+ vi.stubEnv('REDIS_URL', 'redis://localhost:6379');
+ }
+
+ const root = join(__dirname, '../../../../..');
+ execFileSync('node', ['packages/database/dist/run-migrations.js'], {
+ cwd: root,
+ env: { ...process.env, DATABASE_URL: databaseUrl! },
+ stdio: 'pipe',
+ });
+
+ client = postgres(databaseUrl!, { max: 10 });
+ db = drizzle(client);
+ await db.insert(properties).values({
+ id: propertyId,
+ name: 'Booking Request E2E Hotel',
+ code: `BRE2E${propertyId.slice(0, 8)}`,
+ countryCode: 'ES',
+ timezone: 'Europe/Madrid',
+ currencyCode: 'EUR',
+ totalRooms: 2,
+ });
+ await db.insert(roomTypes).values({
+ id: roomTypeId,
+ propertyId,
+ name: 'E2E Suite',
+ code: 'E2ESUITE',
+ maxOccupancy: 4,
+ defaultOccupancy: 2,
+ });
+ await db.insert(rooms).values([
+ {
+ id: randomUUID(),
+ propertyId,
+ roomTypeId,
+ number: `E2E-${propertyId.slice(0, 4)}-1`,
+ },
+ {
+ id: randomUUID(),
+ propertyId,
+ roomTypeId,
+ number: `E2E-${propertyId.slice(0, 4)}-2`,
+ },
+ ]);
+ await db.insert(ratePlans).values({
+ id: ratePlanId,
+ propertyId,
+ roomTypeId,
+ name: 'E2E Flexible',
+ code: 'E2EFLEX',
+ type: 'bar',
+ baseAmount: '100.00',
+ currencyCode: 'EUR',
+ });
+ await db.insert(bookingEngineCredentials).values({
+ propertyId,
+ label: 'Booking Request E2E widget',
+ keyHash: createHash('sha256').update(bookingKey).digest('hex'),
+ keyPrefix: bookingKey.slice(0, 12),
+ });
+ await db.insert(agentWebhookSubscriptions).values(
+ webhookSubscriptionIds.map((id, index) => ({
+ id,
+ propertyId,
+ subscriberId: `booking-request-e2e-${propertyId}-${index + 1}`,
+ subscriberName: `Booking Request E2E subscriber ${index + 1}`,
+ callbackUrl: 'https://8.8.8.8/haip-e2e',
+ events: [
+ 'booking_request.created',
+ 'booking_request.accepted',
+ 'payment.received',
+ 'reservation.modified',
+ ],
+ secret: `booking-request-e2e-secret-${index + 1}`,
+ })),
+ );
+
+ const { preloadBookingRequestsModules } = await import('../../booking-requests.bootstrap.js');
+ await preloadBookingRequestsModules();
+ const { AppModule } = await import('../../app.module');
+ const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
+ .overrideProvider(EmailService)
+ .useValue({
+ send: vi.fn(async (message: { to: string; subject: string; text: string }) => {
+ sentEmails.push(message);
+ return {
+ sent: true,
+ provider: 'booking-request-e2e',
+ messageId: `e2e-${sentEmails.length}`,
+ };
+ }),
+ })
+ .overrideProvider(SAVED_PAYMENT_METHOD_GATEWAY)
+ .useValue(savedPaymentMethodGateway)
+ .overrideProvider(WebhookDeliveryService)
+ .useFactory({
+ factory: (database: unknown, eventEmitter: EventEmitter2) =>
+ new WebhookDeliveryService(
+ database,
+ eventEmitter,
+ { add: async () => undefined },
+ ),
+ inject: [DRIZZLE, EventEmitter2],
+ })
+ .compile();
+ app = moduleRef.createNestApplication();
+ app.setGlobalPrefix('api/v1');
+ app.useGlobalPipes(new ValidationPipe({
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ transform: true,
+ }));
+ app.useGlobalFilters(new AllExceptionsFilter());
+ await app.init();
+ }, 120_000);
+
+ afterAll(async () => {
+ try {
+ await app?.close();
+ } finally {
+ try {
+ if (client) await cleanupPropertyFixture(client, propertyId);
+ } finally {
+ try {
+ await client?.end();
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ }
+ }
+ });
+
+ it('runs request, manual money, acceptance, folio, amendment, and rollout flows together', async () => {
+ const http = request(app.getHttpServer());
+ const publicRequest = () => http.post('/api/v1/booking-engine/requests')
+ .set('x-booking-key', bookingKey);
+
+ const defaultConfig = await http
+ .get('/api/v1/admin/booking-engine/config')
+ .query({ propertyId })
+ .expect(200);
+ expect(defaultConfig.body).toMatchObject({
+ propertyId,
+ isEnabled: false,
+ bookingMode: 'instant',
+ paymentMethodCollection: 'disabled',
+ formQuestions: [],
+ });
+
+ const configResponse = await http
+ .patch('/api/v1/admin/booking-engine/config')
+ .query({ propertyId })
+ .send({
+ isEnabled: true,
+ displayName: 'Booking Request E2E Hotel',
+ bookingMode: 'request',
+ paymentMethodCollection: 'required',
+ stripePublishableKey: 'pk_test_booking_request_e2e',
+ sellableRoomTypeIds: [roomTypeId],
+ sellableRatePlanIds: [ratePlanId],
+ depositPolicy: { type: 'none', refundable: true },
+ formQuestions: [{
+ id: questionId,
+ label: 'Purpose of stay',
+ type: 'single_select',
+ options: [PRIVATE_ANSWER, 'Business'],
+ order: 0,
+ isActive: true,
+ isRequired: true,
+ }],
+ })
+ .expect(200);
+ expect(configResponse.body).toMatchObject({
+ propertyId,
+ bookingMode: 'request',
+ paymentMethodCollection: 'required',
+ });
+
+ const setupResponse = await http
+ .post('/api/v1/booking-engine/request-payment-method-setup')
+ .set('x-booking-key', bookingKey)
+ .send({
+ guestEmail: 'vertical@example.com',
+ applicationId: applicationKey,
+ idempotencyKey: `${applicationKey}-card-attempt-1`,
+ })
+ .expect(201);
+ expect(setupResponse.body.setupIntentId).toBe(PRIVATE_SETUP_INTENT);
+ expect(setupResponse.body.clientSecret).toContain('E2E_PRIVATE_CLIENT_TOKEN');
+
+ await http
+ .post('/api/v1/booking-engine/book')
+ .set('x-booking-key', bookingKey)
+ .send({
+ roomTypeId,
+ ratePlanId,
+ checkIn: instantArrivalDate,
+ checkOut: instantDepartureDate,
+ adults: 2,
+ children: 0,
+ guestFirstName: 'Blocked',
+ guestLastName: 'Instant',
+ guestEmail: 'blocked-instant@example.com',
+ })
+ .expect(403);
+
+ const submitResponse = await publicRequest()
+ .send({
+ idempotencyKey: applicationKey,
+ roomTypeId,
+ ratePlanId,
+ checkIn: arrivalDate,
+ checkOut: departureDate,
+ guestFirstName: 'Vertical',
+ guestLastName: 'Guest',
+ guestEmail: 'vertical@example.com',
+ guestPhone: '+34 600 000 001',
+ adults: 2,
+ children: 0,
+ specialRequests: 'Quiet room',
+ serviceIds: [],
+ applicationAnswers: { [questionId]: PRIVATE_ANSWER },
+ setupIntentId: setupResponse.body.setupIntentId,
+ consentAccepted: true,
+ consentText: PRIVATE_CONSENT,
+ consentVersion: 'v1',
+ })
+ .expect(201);
+ expect(submitResponse.body).toMatchObject({ status: 'pending' });
+ const bookingRequestId = submitResponse.body.requestId as string;
+
+ const pendingRequest = await http
+ .get(`/api/v1/booking-requests/${bookingRequestId}`)
+ .query({ propertyId })
+ .expect(200);
+ expect(pendingRequest.body).toMatchObject({
+ id: bookingRequestId,
+ status: 'pending',
+ submittedTotal: '200.00',
+ acceptedReservationId: null,
+ operationalReservation: null,
+ card: { brand: PRIVATE_CARD_BRAND, lastFour: PRIVATE_CARD_LAST_FOUR },
+ applicationAnswers: { [questionId]: PRIVATE_ANSWER },
+ });
+ expect(pendingRequest.body).not.toHaveProperty('stripePaymentMethodId');
+ expect(await db.select().from(reservations).where(eq(reservations.propertyId, propertyId)))
+ .toHaveLength(0);
+
+ const depositInstallment = await http
+ .post(`/api/v1/booking-requests/${bookingRequestId}/installments`)
+ .query({ propertyId })
+ .send({
+ label: '30% before arrival',
+ sortOrder: 0,
+ percentage: '30.00',
+ dueMilestone: 'arrival',
+ })
+ .expect(201);
+ const balanceInstallment = await http
+ .post(`/api/v1/booking-requests/${bookingRequestId}/installments`)
+ .query({ propertyId })
+ .send({
+ label: '70% at checkout',
+ sortOrder: 1,
+ percentage: '70.00',
+ dueMilestone: 'checkout',
+ })
+ .expect(201);
+ expect(depositInstallment.body).toMatchObject({
+ resolvedAmount: '60.00',
+ status: 'unpaid',
+ });
+ expect(balanceInstallment.body).toMatchObject({
+ resolvedAmount: '140.00',
+ status: 'unpaid',
+ });
+
+ const cardPayment = await http
+ .post(`/api/v1/booking-requests/${bookingRequestId}/payments/charge`)
+ .query({ propertyId })
+ .send({ amount: '30.00', idempotencyKey: `partial-card-${bookingRequestId}` })
+ .expect(201);
+ expect(cardPayment.body).toMatchObject({
+ bookingRequestId,
+ folioId: null,
+ amount: '30.00',
+ status: 'captured',
+ source: 'saved_card',
+ cardLastFour: PRIVATE_CARD_LAST_FOUR,
+ });
+
+ const allocation = await http
+ .post(
+ `/api/v1/booking-requests/${bookingRequestId}/installments/${depositInstallment.body.id}/allocations`,
+ )
+ .query({ propertyId })
+ .send({ paymentId: cardPayment.body.id, amount: '30.00' })
+ .expect(201);
+ expect(allocation.body.installment).toMatchObject({
+ id: depositInstallment.body.id,
+ allocatedAmount: '30.00',
+ status: 'partial',
+ });
+
+ await db
+ .update(ratePlans)
+ .set({ baseAmount: '110.00', updatedAt: new Date() })
+ .where(and(
+ eq(ratePlans.id, ratePlanId),
+ eq(ratePlans.propertyId, propertyId),
+ ));
+
+ const acceptancePreview = await http
+ .get(`/api/v1/booking-requests/${bookingRequestId}/acceptance-preview`)
+ .query({ propertyId })
+ .expect(200);
+ expect(acceptancePreview.body).toMatchObject({
+ submittedTotal: '200.00',
+ currentTotal: '220.00',
+ currencyCode: 'EUR',
+ });
+
+ const accepted = await http
+ .post(`/api/v1/booking-requests/${bookingRequestId}/accept`)
+ .query({ propertyId })
+ .send({ priceSource: 'current', previewToken: acceptancePreview.body.previewToken })
+ .expect(201);
+ expect(accepted.body).toMatchObject({
+ requestId: bookingRequestId,
+ status: 'accepted',
+ totalAmount: '220.00',
+ priceSource: 'current',
+ });
+ const reservationId = accepted.body.reservationId as string;
+ const folioId = accepted.body.folioId as string;
+
+ const [acceptedRequestRows, acceptedReservationRows, acceptedFolioRows] = await Promise.all([
+ db.select().from(bookingRequests).where(and(
+ eq(bookingRequests.id, bookingRequestId),
+ eq(bookingRequests.propertyId, propertyId),
+ )),
+ db.select().from(reservations).where(and(
+ eq(reservations.id, reservationId),
+ eq(reservations.propertyId, propertyId),
+ )),
+ db.select().from(folios).where(and(
+ eq(folios.id, folioId),
+ eq(folios.propertyId, propertyId),
+ )),
+ ]);
+ expect(acceptedRequestRows[0]).toMatchObject({
+ acceptedTotal: '220.00',
+ acceptedReservationId: reservationId,
+ acceptedFolioId: folioId,
+ submittedQuoteSnapshot: expect.objectContaining({ grandTotal: '200.00' }),
+ currentQuoteSnapshot: expect.objectContaining({ grandTotal: '220.00' }),
+ });
+ expect(acceptedReservationRows[0]).toMatchObject({
+ id: reservationId,
+ totalAmount: '220.00',
+ acceptedPricingSnapshot: expect.objectContaining({
+ grandTotal: '220.00',
+ source: 'current',
+ }),
+ });
+ expect(acceptedFolioRows[0]).toMatchObject({
+ id: folioId,
+ reservationId,
+ propertyId,
+ });
+
+ const externalPayment = await http
+ .post(`/api/v1/booking-requests/${bookingRequestId}/payments/external`)
+ .query({ propertyId })
+ .send({
+ amount: '50.00',
+ currencyCode: 'EUR',
+ method: 'bank_transfer',
+ processedAt: new Date().toISOString(),
+ provider: 'bank',
+ reference: `BANK-${bookingRequestId}`,
+ notes: 'Manually reconciled bank transfer',
+ })
+ .expect(201);
+ expect(externalPayment.body).toMatchObject({
+ bookingRequestId,
+ folioId,
+ amount: '50.00',
+ status: 'captured',
+ source: 'external',
+ });
+
+ await http
+ .post(`/api/v1/folios/${folioId}/charges`)
+ .send({
+ propertyId,
+ type: 'minibar',
+ description: 'E2E minibar extra',
+ amount: '25.00',
+ currencyCode: 'EUR',
+ taxAmount: '0.00',
+ serviceDate: arrivalDate,
+ skipTaxCalculation: true,
+ })
+ .expect(201);
+
+ const amendmentPreview = await http
+ .get(`/api/v1/booking-requests/${bookingRequestId}/stay-amendment-preview`)
+ .query({
+ propertyId,
+ arrivalDate,
+ departureDate: extendedDepartureDate,
+ })
+ .expect(200);
+ expect(amendmentPreview.body).toMatchObject({
+ previousTotal: '220.00',
+ currentTotal: '330.00',
+ currencyCode: 'EUR',
+ });
+
+ const amendment = await http
+ .post(`/api/v1/booking-requests/${bookingRequestId}/stay-amendments`)
+ .query({ propertyId })
+ .send({
+ arrivalDate,
+ departureDate: extendedDepartureDate,
+ priceSource: 'current',
+ previewToken: amendmentPreview.body.previewToken,
+ idempotencyKey: `extend-${bookingRequestId}`,
+ })
+ .expect(201);
+ expect(amendment.body).toMatchObject({
+ reservationId,
+ folioId,
+ previousTotalAmount: '220.00',
+ newTotalAmount: '330.00',
+ priceSource: 'current',
+ });
+
+ const finalRequest = await http
+ .get(`/api/v1/booking-requests/${bookingRequestId}`)
+ .query({ propertyId })
+ .expect(200);
+ expect(finalRequest.body).toMatchObject({
+ status: 'accepted',
+ arrivalDate,
+ departureDate,
+ submittedTotal: '200.00',
+ acceptedTotal: '220.00',
+ acceptedReservationId: reservationId,
+ acceptedFolioId: folioId,
+ operationalReservation: {
+ id: reservationId,
+ arrivalDate,
+ departureDate: extendedDepartureDate,
+ totalAmount: '330.00',
+ },
+ });
+
+ const paymentState = await http
+ .get(`/api/v1/booking-requests/${bookingRequestId}/payments`)
+ .query({ propertyId })
+ .expect(200);
+ expect(paymentState.body.movements).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: cardPayment.body.id, folioId, amount: '30.00' }),
+ expect.objectContaining({ id: externalPayment.body.id, folioId, amount: '50.00' }),
+ ]));
+ expect(paymentState.body.allocations).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ paymentId: cardPayment.body.id,
+ installmentId: depositInstallment.body.id,
+ amount: '30.00',
+ }),
+ ]));
+
+ const folioState = await http
+ .get(`/api/v1/folios/${folioId}`)
+ .query({ propertyId })
+ .expect(200);
+ expect(folioState.body).toMatchObject({
+ id: folioId,
+ reservationId,
+ currencyCode: 'EUR',
+ totalCharges: '25.00',
+ totalPayments: '80.00',
+ balance: '-55.00',
+ });
+
+ const emailState = await http
+ .get(`/api/v1/booking-requests/${bookingRequestId}/emails`)
+ .query({ propertyId })
+ .expect(200);
+ expect(emailState.body.map((delivery: { kind: string }) => delivery.kind)).toEqual([
+ 'receipt',
+ 'payment',
+ 'accepted',
+ 'payment',
+ ]);
+ expect(emailState.body.every((delivery: { status: string }) => delivery.status === 'sent'))
+ .toBe(true);
+ expect(sentEmails).toHaveLength(4);
+ expect(sentEmails.every((message) => message.to === 'vertical@example.com')).toBe(true);
+
+ const auditState = await http
+ .get(`/api/v1/booking-requests/${bookingRequestId}/audit-history`)
+ .query({ propertyId, limit: 100 })
+ .expect(200);
+ const summaries = auditState.body.data.map((item: { summary: string }) => item.summary);
+ expect(summaries).toEqual(expect.arrayContaining([
+ 'request.accepted',
+ 'installment.created',
+ 'allocation.recorded',
+ 'payment.captured',
+ 'email.sent',
+ 'stay.amended',
+ ]));
+
+ const databaseState = await Promise.all([
+ db.select().from(bookingRequests).where(and(
+ eq(bookingRequests.id, bookingRequestId),
+ eq(bookingRequests.propertyId, propertyId),
+ )),
+ db.select().from(reservations).where(and(
+ eq(reservations.id, reservationId),
+ eq(reservations.propertyId, propertyId),
+ )),
+ db.select().from(bookingRequestInstallments).where(eq(
+ bookingRequestInstallments.bookingRequestId,
+ bookingRequestId,
+ )),
+ db.select().from(payments).where(eq(payments.bookingRequestId, bookingRequestId)),
+ db.select().from(charges).where(eq(charges.folioId, folioId)),
+ db.select().from(bookingRequestEmailDeliveries).where(eq(
+ bookingRequestEmailDeliveries.bookingRequestId,
+ bookingRequestId,
+ )),
+ db.select().from(auditLogs).where(eq(auditLogs.bookingRequestId, bookingRequestId)),
+ ]);
+ expect(databaseState.map((rows) => rows.length)).toEqual([1, 1, 2, 2, 1, 4, expect.any(Number)]);
+ expect(databaseState[6]!.length).toBeGreaterThanOrEqual(12);
+ const persistedRequest = databaseState[0]![0] as typeof bookingRequests.$inferSelect;
+ const persistedReservation = databaseState[1]![0] as typeof reservations.$inferSelect;
+ expect(persistedRequest).toMatchObject({
+ acceptedTotal: '220.00',
+ });
+ expect(persistedRequest.submittedQuoteSnapshot).toMatchObject({ grandTotal: '200.00' });
+ expect(persistedRequest.currentQuoteSnapshot).toMatchObject({ grandTotal: '220.00' });
+ expect(persistedReservation).toMatchObject({
+ id: reservationId,
+ totalAmount: '330.00',
+ });
+ expect(persistedReservation.acceptedPricingSnapshot).toMatchObject({
+ grandTotal: '330.00',
+ source: 'current',
+ });
+
+ const consequenceRows = await db
+ .select()
+ .from(bookingRequestConsequences)
+ .where(and(
+ eq(bookingRequestConsequences.propertyId, propertyId),
+ eq(bookingRequestConsequences.bookingRequestId, bookingRequestId),
+ ));
+ const targetConsequences = consequenceRows.filter((row) => [
+ 'booking_request.created',
+ 'booking_request.accepted',
+ 'payment.received',
+ 'reservation.modified',
+ ].includes((row.payload as { event?: string }).event ?? ''));
+ expect(targetConsequences.map((row) => (row.payload as { event: string }).event).sort())
+ .toEqual([
+ 'booking_request.accepted',
+ 'booking_request.created',
+ 'payment.received',
+ 'payment.received',
+ 'reservation.modified',
+ ]);
+ expect(new Set(targetConsequences.map((row) => row.id)).size)
+ .toBe(targetConsequences.length);
+ expect(targetConsequences.every((row) =>
+ row.status === 'completed'
+ && row.attempts === 1
+ && row.completedAt instanceof Date)).toBe(true);
+
+ const queuedDeliveries = await db
+ .select()
+ .from(webhookDeliveries)
+ .where(and(
+ eq(webhookDeliveries.propertyId, propertyId),
+ inArray(webhookDeliveries.subscriptionId, webhookSubscriptionIds),
+ ));
+ expect(queuedDeliveries).toHaveLength(
+ targetConsequences.length * webhookSubscriptionIds.length,
+ );
+ expect(queuedDeliveries.every((delivery) =>
+ delivery.status === 'pending'
+ && delivery.attempts === 0
+ && delivery.deliveredAt === null)).toBe(true);
+ for (const consequence of targetConsequences) {
+ const matchingDeliveries = queuedDeliveries.filter((delivery) =>
+ delivery.logicalEventId === consequence.id);
+ expect(matchingDeliveries.map((delivery) => delivery.subscriptionId).sort())
+ .toEqual([...webhookSubscriptionIds].sort());
+ expect(matchingDeliveries.every((delivery) =>
+ delivery.eventType === (consequence.payload as { event: string }).event)).toBe(true);
+ }
+
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
+ Promise.resolve({
+ ok: true,
+ status: 204,
+ } as unknown as Awaited>));
+ try {
+ const deliveryService = app.get(WebhookDeliveryService);
+ for (const delivery of queuedDeliveries) {
+ await expect(deliveryService.attemptDelivery(delivery.id, propertyId))
+ .resolves.toBe('delivered');
+ }
+ const outboundEventIds = fetchMock.mock.calls.map(([, init]) =>
+ new Headers(init?.headers).get('X-HAIP-Event-Id'));
+ expect(outboundEventIds.sort()).toEqual(
+ queuedDeliveries.map((delivery) => delivery.logicalEventId).sort(),
+ );
+ } finally {
+ fetchMock.mockRestore();
+ }
+ const deliveredDeliveries = await db
+ .select()
+ .from(webhookDeliveries)
+ .where(and(
+ eq(webhookDeliveries.propertyId, propertyId),
+ inArray(webhookDeliveries.subscriptionId, webhookSubscriptionIds),
+ ));
+ expect(deliveredDeliveries.every((delivery) =>
+ delivery.status === 'delivered'
+ && delivery.attempts === 1
+ && delivery.deliveredAt instanceof Date)).toBe(true);
+
+ const payloads = targetConsequences.map((row) => row.payload).concat(
+ deliveredDeliveries.map((row) => row.payload as Record),
+ );
+ const payloadLeaves = collectPayloadLeaves(payloads);
+ expect(payloadLeaves.filter(({ path }) => path.some((segment) =>
+ /answer|card|lastFour|consent|paymentMethod|setupIntent|token/i.test(segment))))
+ .toEqual([]);
+ const payloadStringValues = payloadLeaves
+ .map(({ value }) => value)
+ .filter((value): value is string => typeof value === 'string');
+ for (const privateValue of [
+ PRIVATE_ANSWER,
+ PRIVATE_CONSENT,
+ PRIVATE_SETUP_INTENT,
+ PRIVATE_PAYMENT_METHOD,
+ PRIVATE_CARD_BRAND,
+ ]) {
+ expect(payloadStringValues).not.toContain(privateValue);
+ }
+ expect(JSON.stringify(payloads)).not.toContain('E2E_PRIVATE_');
+
+ const createdConsequence = targetConsequences.find((row) =>
+ (row.payload as { event?: string }).event === 'booking_request.created')!;
+ const createdDelivery = deliveredDeliveries.find((row) =>
+ row.logicalEventId === createdConsequence.id
+ && row.subscriptionId === webhookSubscriptionIds[0])!;
+ await app.get(WebhookService).dispatchPersisted(
+ createdConsequence.payload as unknown as WebhookPayload,
+ createdConsequence.id,
+ );
+ const deduplicatedDeliveries = await db
+ .select()
+ .from(webhookDeliveries)
+ .where(and(
+ eq(webhookDeliveries.propertyId, propertyId),
+ eq(webhookDeliveries.subscriptionId, webhookSubscriptionIds[0]!),
+ eq(webhookDeliveries.logicalEventId, createdConsequence.id),
+ ));
+ expect(deduplicatedDeliveries).toEqual([
+ expect.objectContaining({
+ id: createdDelivery.id,
+ logicalEventId: createdConsequence.id,
+ status: 'delivered',
+ }),
+ ]);
+
+ await expect(db.insert(payments).values({
+ propertyId,
+ method: 'cash',
+ status: 'captured',
+ amount: '1.00',
+ currencyCode: 'EUR',
+ processedAt: new Date(),
+ })).rejects.toThrow(/payments_financial_target_check/);
+
+ await http
+ .patch('/api/v1/admin/booking-engine/config')
+ .query({ propertyId })
+ .send({ bookingMode: 'instant', paymentMethodCollection: 'disabled' })
+ .expect(200);
+
+ await publicRequest()
+ .send({
+ idempotencyKey: `request-disabled-${randomUUID()}`,
+ roomTypeId,
+ ratePlanId,
+ checkIn: instantArrivalDate,
+ checkOut: instantDepartureDate,
+ guestFirstName: 'Disabled',
+ guestLastName: 'Request',
+ guestEmail: 'disabled-request@example.com',
+ adults: 2,
+ applicationAnswers: { [questionId]: 'Business' },
+ })
+ .expect(403);
+
+ const instantBooking = await http
+ .post('/api/v1/booking-engine/book')
+ .set('x-booking-key', bookingKey)
+ .send({
+ roomTypeId,
+ ratePlanId,
+ checkIn: instantArrivalDate,
+ checkOut: instantDepartureDate,
+ adults: 2,
+ children: 0,
+ guestFirstName: 'Instant',
+ guestLastName: 'Guest',
+ guestEmail: 'instant@example.com',
+ })
+ .expect(201);
+ expect(instantBooking.body).toMatchObject({ success: true });
+
+ await http
+ .get(`/api/v1/booking-requests/${bookingRequestId}`)
+ .query({ propertyId })
+ .expect(200)
+ .expect(({ body }) => {
+ expect(body).toMatchObject({ id: bookingRequestId, status: 'accepted' });
+ });
+
+ vi.stubEnv('AUTH_ENABLED', 'true');
+ try {
+ await http
+ .get('/api/v1/booking-engine/config')
+ .expect(401);
+ await http
+ .get('/api/v1/booking-engine/config')
+ .set('x-booking-key', `pk_invalid_${randomUUID()}`)
+ .expect(401);
+ await http
+ .post('/api/v1/booking-engine/requests')
+ .set('x-booking-key', bookingKey)
+ .send({ propertyId: randomUUID() })
+ .expect(403);
+ await http
+ .get('/api/v1/booking-engine/config')
+ .set('x-booking-key', bookingKey)
+ .expect(200)
+ .expect(({ body }) => {
+ expect(body).toMatchObject({
+ propertyId,
+ bookingMode: 'instant',
+ });
+ });
+ await http
+ .get('/api/v1/booking-engine/requests')
+ .set('x-booking-key', bookingKey)
+ .expect(404);
+ await http
+ .get(`/api/v1/booking-engine/requests/${bookingRequestId}`)
+ .set('x-booking-key', bookingKey)
+ .expect(404);
+ } finally {
+ vi.stubEnv('AUTH_ENABLED', 'false');
+ }
+ }, 120_000);
+});
+
+function collectPayloadLeaves(
+ value: unknown,
+ path: string[] = [],
+): Array<{ path: string[]; value: unknown }> {
+ if (Array.isArray(value)) {
+ return value.flatMap((nested, index) =>
+ collectPayloadLeaves(nested, [...path, String(index)]));
+ }
+ if (!value || typeof value !== 'object') return [{ path, value }];
+ return Object.entries(value).flatMap(([key, nested]) =>
+ collectPayloadLeaves(nested, [...path, key]));
+}
+
+async function cleanupPropertyFixture(
+ sqlClient: ReturnType,
+ propertyId: string,
+): Promise {
+ const guestRows = await sqlClient<{ id: string }[]>`
+ SELECT DISTINCT guest_id AS id
+ FROM reservations
+ WHERE property_id = ${propertyId}
+ `;
+ const propertyTables = await sqlClient<{ tableName: string }[]>`
+ SELECT table_name AS "tableName"
+ FROM information_schema.columns
+ WHERE table_schema = 'public' AND column_name = 'property_id'
+ ORDER BY table_name
+ `;
+ const foreignKeys = await sqlClient>`
+ SELECT
+ child.relname AS "childTable",
+ parent.relname AS "parentTable"
+ FROM pg_constraint constraint_row
+ JOIN pg_class child ON child.oid = constraint_row.conrelid
+ JOIN pg_class parent ON parent.oid = constraint_row.confrelid
+ JOIN pg_namespace child_namespace ON child_namespace.oid = child.relnamespace
+ JOIN pg_namespace parent_namespace ON parent_namespace.oid = parent.relnamespace
+ WHERE constraint_row.contype = 'f'
+ AND child_namespace.nspname = 'public'
+ AND parent_namespace.nspname = 'public'
+ `;
+ const deletionOrder = childFirstTableOrder(
+ [...propertyTables.map(({ tableName }) => tableName), 'properties'],
+ foreignKeys,
+ );
+ await sqlClient.begin(async (transaction) => {
+ for (const tableName of deletionOrder) {
+ const quotedTable = `"${tableName.replaceAll('"', '""')}"`;
+ const propertyColumn = tableName === 'properties' ? 'id' : 'property_id';
+ await transaction.unsafe(
+ `DELETE FROM ${quotedTable} WHERE ${propertyColumn} = $1`,
+ [propertyId],
+ );
+ }
+ for (const guest of guestRows) {
+ await transaction`DELETE FROM guests WHERE id = ${guest.id}`;
+ }
+ });
+ const leftovers = await sqlClient<{ count: number }[]>`
+ SELECT count(*)::int AS count FROM properties WHERE id = ${propertyId}
+ `;
+ if (leftovers[0]?.count !== 0) {
+ throw new Error(`Booking Request E2E fixture ${propertyId} was not removed`);
+ }
+}
+
+function childFirstTableOrder(
+ tableNames: string[],
+ foreignKeys: Array<{ childTable: string; parentTable: string }>,
+): string[] {
+ const remaining = new Set(tableNames);
+ const scopedForeignKeys = foreignKeys.filter(({ childTable, parentTable }) =>
+ childTable !== parentTable
+ && remaining.has(childTable)
+ && remaining.has(parentTable));
+ const ordered: string[] = [];
+ while (remaining.size > 0) {
+ const children = [...remaining]
+ .filter((candidate) => !scopedForeignKeys.some(({ childTable, parentTable }) =>
+ parentTable === candidate
+ && remaining.has(childTable)
+ && remaining.has(parentTable)))
+ .sort();
+ if (children.length === 0) {
+ throw new Error(
+ `Cannot clean Booking Request E2E fixture: property table FK cycle (${[
+ ...remaining,
+ ].sort().join(', ')})`,
+ );
+ }
+ for (const child of children) {
+ ordered.push(child);
+ remaining.delete(child);
+ }
+ }
+ return ordered;
+}
diff --git a/apps/api/src/modules/booking-request/regression-database-utils.ts b/apps/api/src/modules/booking-request/regression-database-utils.ts
new file mode 100644
index 00000000..dc0efeda
--- /dev/null
+++ b/apps/api/src/modules/booking-request/regression-database-utils.ts
@@ -0,0 +1,173 @@
+/**
+ * Shared ephemeral-Postgres-database helpers for the release-gate regression
+ * specs in this directory (see booking-request-default-flow-regression.spec.ts
+ * and booking-request-flag-off-instant-booking.regression.spec.ts). Each spec
+ * provisions its own scratch database against the same DATABASE_URL host so
+ * the two regression suites never collide with each other or with `haip_test`.
+ *
+ * Extracted so the flag-off regression spec does not have to re-duplicate the
+ * subprocess-sanitization logic (createdb/dropdb via psql utilities, with a
+ * Docker-exec fallback for containerized Postgres, and credential redaction
+ * on failure).
+ */
+import { execFileSync } from 'node:child_process';
+
+export const DATABASE_UTILITY_TIMEOUT_MS = 30_000;
+
+function isMissingExecutable(error: unknown): boolean {
+ return error instanceof Error
+ && 'code' in error
+ && (error as NodeJS.ErrnoException).code === 'ENOENT';
+}
+
+export function createRegressionDatabaseHelpers(connectionTemplate: string) {
+ function databaseUrlFor(databaseName: string): string {
+ const url = new URL(connectionTemplate);
+ url.pathname = `/${databaseName}`;
+ return url.toString();
+ }
+
+ function sanitizeDiagnostic(value: string, secret: string): string {
+ const structurallySanitized = value
+ .replace(/\b(postgres(?:ql)?:\/\/)[^\s/?#@]*@/gi, '$1')
+ .replace(
+ /\b(password\s*=\s*)(?:'(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*"|(?:\\[\s\S]|[^\s])+)/gi,
+ '$1[redacted]',
+ );
+ const sensitiveValues = [
+ secret,
+ encodeURIComponent(secret),
+ connectionTemplate,
+ databaseUrlFor('postgres'),
+ ].filter(Boolean);
+ return sensitiveValues.reduce(
+ (sanitized, sensitive) => sanitized.replaceAll(sensitive, '[redacted]'),
+ structurallySanitized,
+ ).slice(-2_000);
+ }
+
+ function sanitizedChildError(label: string, error: unknown, secret: string): Error {
+ const childError = error as {
+ code?: string | number;
+ message?: string;
+ status?: number | null;
+ signal?: NodeJS.Signals | null;
+ stderr?: Buffer | string;
+ };
+ const rawDetail = childError.stderr?.toString().trim()
+ || childError.message?.trim()
+ || '';
+ const detail = sanitizeDiagnostic(rawDetail, secret);
+ const rawOutcome = childError.signal
+ ? `signal ${childError.signal}`
+ : childError.status !== undefined && childError.status !== null
+ ? `exit ${childError.status}`
+ : childError.code !== undefined
+ ? `code ${childError.code}`
+ : 'exit unknown';
+ const outcome = sanitizeDiagnostic(rawOutcome, secret);
+ return new Error(`${label} failed (${outcome})${detail ? `: ${detail}` : ''}`);
+ }
+
+ function execFileBounded(
+ command: string,
+ args: string[],
+ options: {
+ env: NodeJS.ProcessEnv;
+ label: string;
+ secret: string;
+ timeout: number;
+ tolerateMissing?: boolean;
+ cwd?: string;
+ },
+ ): Buffer | undefined {
+ try {
+ return execFileSync(command, args, {
+ env: options.env,
+ cwd: options.cwd,
+ stdio: 'pipe',
+ timeout: options.timeout,
+ });
+ } catch (error: unknown) {
+ if (options.tolerateMissing && isMissingExecutable(error)) return undefined;
+ throw sanitizedChildError(options.label, error, options.secret);
+ }
+ }
+
+ function runDatabaseUtility(command: 'createdb' | 'dropdb', databaseName: string): void {
+ const maintenanceUrl = new URL(connectionTemplate);
+ maintenanceUrl.pathname = '/postgres';
+ const databasePassword = decodeURIComponent(maintenanceUrl.password);
+ const publicMaintenanceUrl = new URL(maintenanceUrl);
+ publicMaintenanceUrl.password = '';
+ const utilityArgs = [
+ `--maintenance-db=${publicMaintenanceUrl.toString()}`,
+ '--no-password',
+ ...(command === 'dropdb' ? ['--if-exists', '--force'] : []),
+ databaseName,
+ ];
+ const childEnv = { ...process.env, PGPASSWORD: databasePassword };
+ const hostResult = execFileBounded(command, utilityArgs, {
+ env: childEnv,
+ label: `PostgreSQL ${command}`,
+ secret: databasePassword,
+ timeout: DATABASE_UTILITY_TIMEOUT_MS,
+ tolerateMissing: true,
+ });
+ if (hostResult !== undefined) return;
+
+ const host = maintenanceUrl.hostname;
+ if (!['localhost', '127.0.0.1', '::1', '[::1]'].includes(host)) {
+ throw new Error(
+ `${command} is required to provision the remote PostgreSQL test database`,
+ );
+ }
+ const publishedPort = maintenanceUrl.port || '5432';
+ const containerOutput = execFileBounded('docker', [
+ 'ps',
+ '--filter',
+ `publish=${publishedPort}`,
+ '--format',
+ '{{.Names}}',
+ ], {
+ env: childEnv,
+ label: 'PostgreSQL container lookup',
+ secret: databasePassword,
+ timeout: DATABASE_UTILITY_TIMEOUT_MS,
+ });
+ const containers = containerOutput!.toString().trim().split('\n').filter(Boolean);
+ if (containers.length !== 1) {
+ throw new Error(
+ `${command} is unavailable and PostgreSQL container lookup for port ${publishedPort} `
+ + `returned ${containers.length} matches`,
+ );
+ }
+ execFileBounded('docker', [
+ 'exec',
+ '--env',
+ 'PGPASSWORD',
+ containers[0]!,
+ command,
+ '--username',
+ decodeURIComponent(maintenanceUrl.username),
+ '--maintenance-db',
+ 'postgres',
+ '--no-password',
+ ...(command === 'dropdb' ? ['--if-exists', '--force'] : []),
+ databaseName,
+ ], {
+ env: childEnv,
+ label: `containerized PostgreSQL ${command}`,
+ secret: databasePassword,
+ timeout: DATABASE_UTILITY_TIMEOUT_MS,
+ });
+ }
+
+ return {
+ databaseUrlFor,
+ execFileBounded,
+ runDatabaseUtility,
+ sanitizedChildError,
+ sanitizeDiagnostic,
+ };
+}
diff --git a/apps/api/src/modules/connect/connect-credentials.service.spec.ts b/apps/api/src/modules/connect/connect-credentials.service.spec.ts
index 9d3c6790..f209fc48 100644
--- a/apps/api/src/modules/connect/connect-credentials.service.spec.ts
+++ b/apps/api/src/modules/connect/connect-credentials.service.spec.ts
@@ -10,6 +10,14 @@ vi.mock('drizzle-orm', () => ({
}));
vi.mock('@telivityhaip/database', () => ({
+ // `database.module.ts` (imported transitively via `../auth/api-key.guard`
+ // → `DRIZZLE`) now re-exports these two from `@telivityhaip/database`
+ // itself (a single canonical `DRIZZLE` symbol shared across the optional
+ // `@telivityhaip/booking-requests` package boundary) instead of defining
+ // its own local symbol — this narrow mock must supply both so that static
+ // import doesn't throw, even though this test never uses either value.
+ DRIZZLE: Symbol('DRIZZLE-test-mock'),
+ postgresOptionsFromEnv: vi.fn(() => ({})),
auditLogs: {
__table: 'auditLogs',
propertyId: 'audit.propertyId',
diff --git a/apps/api/src/modules/folio/dto/create-charge.dto.ts b/apps/api/src/modules/folio/dto/create-charge.dto.ts
index 20a34a5c..6c17bb67 100644
--- a/apps/api/src/modules/folio/dto/create-charge.dto.ts
+++ b/apps/api/src/modules/folio/dto/create-charge.dto.ts
@@ -7,7 +7,6 @@ import {
IsBoolean,
IsDateString,
MaxLength,
- ValidateIf,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsMoneyString } from '../../../common/validation/is-money-string.validator';
@@ -32,8 +31,8 @@ export class CreateChargeDto {
@ApiProperty({ example: '150.00', description: 'Charge amount (positive for charges, negative for credits)' })
// Must be a valid numeric decimal; negatives are allowed here at the DTO layer
- // because credits/adjustments are legitimate — the service restricts WHEN a
- // negative is permitted (only type='adjustment' or reversals).
+ // because credits/adjustments are legitimate — the service restricts them to
+ // type='adjustment'. Canonical reversals have a separate service operation.
@IsMoneyString({ allowNegative: true })
amount!: string;
@@ -63,17 +62,6 @@ export class CreateChargeDto {
@IsDateString()
serviceDate!: string;
- @ApiPropertyOptional({ default: false })
- @IsOptional()
- @IsBoolean()
- isReversal?: boolean;
-
- @ApiPropertyOptional({ description: 'Original charge ID (required if isReversal=true)' })
- @IsOptional()
- @IsUUID()
- @ValidateIf((o) => o.isReversal === true)
- originalChargeId?: string;
-
@ApiPropertyOptional({ description: 'Staff user ID who posted this charge' })
@IsOptional()
@IsUUID()
diff --git a/apps/api/src/modules/folio/folio-charge-validation.spec.ts b/apps/api/src/modules/folio/folio-charge-validation.spec.ts
index c4e6f7f2..1fc61773 100644
--- a/apps/api/src/modules/folio/folio-charge-validation.spec.ts
+++ b/apps/api/src/modules/folio/folio-charge-validation.spec.ts
@@ -74,47 +74,24 @@ describe('FolioService.postCharge — amount sign rules', () => {
expect(db.insert).toHaveBeenCalled();
});
- it('rejects posting a reversal of a reversal transaction', async () => {
- let selectCallCount = 0;
- const db = {
- select: vi.fn().mockImplementation(() => ({
- from: vi.fn().mockReturnValue({
- where: vi.fn().mockImplementation(() => {
- selectCallCount++;
- // 1: findById (folio), 2: originalChargeId lookup
- if (selectCallCount === 1) {
- return Promise.resolve([{ id: 'f-1', propertyId: A, status: 'open' }]);
- }
- return Promise.resolve([
- {
- id: 'c-rev',
- propertyId: A,
- folioId: 'f-1',
- isReversal: true,
- isLocked: false,
- },
- ]);
- }),
- }),
- })),
- insert: vi.fn().mockReturnValue({
- values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([{ id: 'c-1' }]) }),
- }),
- update: vi.fn().mockReturnValue({
- set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
- }),
- };
+ it.each([
+ { isReversal: true },
+ { originalChargeId: 'c-base' },
+ { adjustsChargeId: 'c-base' },
+ { parentChargeId: 'c-base' },
+ { sourceKey: 'accepted-pricing:forged' },
+ ])('rejects forged internal provenance before any generic ledger write: %j', async (forged) => {
+ const db = mkDb();
const svc = await mkSvc(db);
await expect(
svc.postCharge('f-1', {
...baseCharge,
type: 'room',
- amount: '-50.00',
- isReversal: true,
- originalChargeId: 'c-rev',
+ amount: '50.00',
+ ...forged,
} as any),
- ).rejects.toThrow('Cannot reverse a reversal transaction');
+ ).rejects.toThrow(/internal charge provenance/i);
expect(db.insert).not.toHaveBeenCalled();
});
diff --git a/apps/api/src/modules/folio/folio-create-charge-http.spec.ts b/apps/api/src/modules/folio/folio-create-charge-http.spec.ts
new file mode 100644
index 00000000..cbf4d1b6
--- /dev/null
+++ b/apps/api/src/modules/folio/folio-create-charge-http.spec.ts
@@ -0,0 +1,77 @@
+import type { INestApplication } from '@nestjs/common';
+import { ValidationPipe } from '@nestjs/common';
+import { Test } from '@nestjs/testing';
+import request from 'supertest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { FiscalDocumentService } from './fiscal-document.service';
+import { FolioController } from './folio.controller';
+import { FolioRoutingService } from './folio-routing.service';
+import { FolioService } from './folio.service';
+
+const FOLIO = '12000000-0000-4000-a000-000000000001';
+const PROPERTY = '12000000-0000-4000-a000-000000000002';
+const CHARGE = '12000000-0000-4000-a000-000000000003';
+
+describe('POST /folios/:id/charges public provenance boundary', () => {
+ let app: INestApplication;
+ const folioService = { postCharge: vi.fn().mockResolvedValue({ id: CHARGE }) };
+
+ beforeEach(async () => {
+ folioService.postCharge.mockClear();
+ const module = await Test.createTestingModule({
+ controllers: [FolioController],
+ providers: [
+ { provide: FolioService, useValue: folioService },
+ { provide: FolioRoutingService, useValue: {} },
+ { provide: FiscalDocumentService, useValue: {} },
+ ],
+ }).compile();
+ app = module.createNestApplication();
+ app.useGlobalPipes(new ValidationPipe({
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ transform: true,
+ }));
+ await app.init();
+ });
+
+ afterEach(async () => {
+ await app.close();
+ });
+
+ const validCharge = () => ({
+ propertyId: PROPERTY,
+ type: 'room',
+ description: 'Room tariff',
+ amount: '100.00',
+ currencyCode: 'EUR',
+ serviceDate: '2026-10-01',
+ });
+
+ it.each([
+ { isReversal: true },
+ { originalChargeId: CHARGE },
+ { adjustsChargeId: CHARGE },
+ { parentChargeId: CHARGE },
+ { sourceKey: 'accepted-pricing:forged' },
+ ])('rejects forged internal charge provenance %j at the HTTP DTO boundary', async (forged) => {
+ await request(app.getHttpServer())
+ .post(`/folios/${FOLIO}/charges`)
+ .send({ ...validCharge(), ...forged })
+ .expect(400);
+
+ expect(folioService.postCharge).not.toHaveBeenCalled();
+ });
+
+ it('still accepts an ordinary public charge', async () => {
+ await request(app.getHttpServer())
+ .post(`/folios/${FOLIO}/charges`)
+ .send(validCharge())
+ .expect(201);
+
+ expect(folioService.postCharge).toHaveBeenCalledWith(
+ FOLIO,
+ expect.not.objectContaining({ isReversal: expect.anything() }),
+ );
+ });
+});
diff --git a/apps/api/src/modules/folio/folio-routing.service.spec.ts b/apps/api/src/modules/folio/folio-routing.service.spec.ts
index 7e303f41..678c234e 100644
--- a/apps/api/src/modules/folio/folio-routing.service.spec.ts
+++ b/apps/api/src/modules/folio/folio-routing.service.spec.ts
@@ -302,8 +302,9 @@ describe('FolioRoutingService', () => {
});
describe('moveTransactions (KB 14.2)', () => {
- function moveDb(matchingCharges: any[]) {
+ function moveDb(matchingCharges: any[], parentCharges: any[] = []) {
let call = 0;
+ let thenCall = 0;
const db: any = {
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnValue({
@@ -315,7 +316,7 @@ describe('FolioRoutingService', () => {
{ ...mockFolio, id: idx === 0 ? 'folio-001' : 'folio-002', status: 'open' },
]);
}),
- then: (resolve: any) => resolve(matchingCharges),
+ then: (resolve: any) => resolve(thenCall++ === 0 ? matchingCharges : parentCharges),
}),
}),
})),
@@ -376,6 +377,57 @@ describe('FolioRoutingService', () => {
svc.moveTransactions('prop-001', 'folio-001', 'folio-002', { chargeType: 'room' }),
).rejects.toThrow(BadRequestException);
});
+
+ it('rejects moving an internal accepted-pricing correction', async () => {
+ const db = moveDb([{
+ id: 'correction-1',
+ type: 'room',
+ amount: '-20.00',
+ isLocked: false,
+ adjustsChargeId: 'accepted-base-1',
+ }]);
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ FolioRoutingService,
+ { provide: DRIZZLE, useValue: db },
+ { provide: FolioService, useValue: mockFolioService },
+ { provide: WebhookService, useValue: mockWebhookService },
+ ],
+ }).compile();
+ const svc = module.get(FolioRoutingService);
+
+ await expect(
+ svc.moveTransactions('prop-001', 'folio-001', 'folio-002', {
+ chargeId: 'correction-1',
+ }),
+ ).rejects.toThrow(/accepted-pricing correction/i);
+ expect(db.update).not.toHaveBeenCalled();
+ });
+
+ it('rejects moving a child of an accepted-pricing group', async () => {
+ const db = moveDb([{
+ id: 'accepted-tax', type: 'tax', isLocked: false, parentChargeId: 'accepted-base',
+ }], [{
+ id: 'accepted-base',
+ sourceKey: 'accepted-pricing:reservation:res-1:night:2026-04-04',
+ }]);
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ FolioRoutingService,
+ { provide: DRIZZLE, useValue: db },
+ { provide: FolioService, useValue: mockFolioService },
+ { provide: WebhookService, useValue: mockWebhookService },
+ ],
+ }).compile();
+ const svc = module.get(FolioRoutingService);
+
+ await expect(
+ svc.moveTransactions('prop-001', 'folio-001', 'folio-002', {
+ chargeId: 'accepted-tax',
+ }),
+ ).rejects.toThrow(/accepted-pricing group/i);
+ expect(db.update).not.toHaveBeenCalled();
+ });
});
describe('transferToCityLedger', () => {
diff --git a/apps/api/src/modules/folio/folio-routing.service.ts b/apps/api/src/modules/folio/folio-routing.service.ts
index c319f6d7..783bd6b0 100644
--- a/apps/api/src/modules/folio/folio-routing.service.ts
+++ b/apps/api/src/modules/folio/folio-routing.service.ts
@@ -216,6 +216,37 @@ export class FolioRoutingService {
if (matching.some((c: any) => c.isLocked)) {
throw new BadRequestException('Cannot move locked (night-audited) charges');
}
+ if (matching.some((c: any) => c.adjustsChargeId)) {
+ throw new BadRequestException(
+ 'Cannot move an internal accepted-pricing correction',
+ );
+ }
+ if (matching.some((c: any) =>
+ typeof c.sourceKey === 'string' && c.sourceKey.startsWith('accepted-pricing:'))) {
+ throw new BadRequestException(
+ 'Cannot move an accepted-pricing group individually',
+ );
+ }
+ const parentIds = [...new Set(
+ matching.map((charge: any) => charge.parentChargeId).filter(Boolean),
+ )] as string[];
+ if (parentIds.length > 0) {
+ const parents = await tx
+ .select()
+ .from(charges)
+ .where(and(
+ eq(charges.propertyId, propertyId),
+ eq(charges.folioId, fromFolioId),
+ inArray(charges.id, parentIds),
+ ));
+ if (parents.some((parent: any) =>
+ typeof parent.sourceKey === 'string'
+ && parent.sourceKey.startsWith('accepted-pricing:'))) {
+ throw new BadRequestException(
+ 'Cannot move a child of an accepted-pricing group individually',
+ );
+ }
+ }
const ids = matching.map((c: any) => c.id);
await tx
diff --git a/apps/api/src/modules/folio/folio-stay-amendment.spec.ts b/apps/api/src/modules/folio/folio-stay-amendment.spec.ts
new file mode 100644
index 00000000..c726a68a
--- /dev/null
+++ b/apps/api/src/modules/folio/folio-stay-amendment.spec.ts
@@ -0,0 +1,866 @@
+import type { AcceptedPricingSnapshot } from '@telivityhaip/database';
+import { ConflictException } from '@nestjs/common';
+import Decimal from 'decimal.js';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { FolioService } from './folio.service';
+
+const oldPricing: AcceptedPricingSnapshot = {
+ version: 1,
+ source: 'current',
+ currencyCode: 'EUR',
+ grandTotal: '220.00',
+ roomTotal: '200.00',
+ taxTotal: '20.00',
+ nights: [
+ { date: '2026-10-01', roomAmount: '100.00', taxAmount: '10.00' },
+ { date: '2026-10-02', roomAmount: '100.00', taxAmount: '10.00' },
+ ],
+ services: [],
+ servicesTotal: '0.00',
+ servicesTaxTotal: '0.00',
+ customReason: null,
+ adjustment: null,
+};
+
+const PROPERTY = 'property-1';
+const FOLIO = 'folio-1';
+const RESERVATION = 'reservation-1';
+const AMENDMENT = 'amendment-1';
+
+function roomGroup(
+ date: string,
+ suffix: string,
+ options: { locked?: boolean; room?: string; tax?: string } = {},
+) {
+ const baseId = `room-${suffix}`;
+ return [
+ {
+ id: baseId,
+ propertyId: PROPERTY,
+ folioId: FOLIO,
+ type: 'room',
+ description: `Room tariff - ${date}`,
+ amount: options.room ?? '100.00',
+ taxAmount: '0.00',
+ currencyCode: 'EUR',
+ serviceDate: new Date(`${date}T00:00:00.000Z`),
+ isReversal: false,
+ originalChargeId: null,
+ parentChargeId: null,
+ sourceKey: `accepted-pricing:reservation:${RESERVATION}:night:${date}`,
+ isLocked: options.locked ?? false,
+ lockedByAuditDate: options.locked ? date : null,
+ },
+ {
+ id: `tax-${suffix}`,
+ propertyId: PROPERTY,
+ folioId: FOLIO,
+ type: 'tax',
+ description: `Room tariff - ${date} tax`,
+ amount: options.tax ?? '10.00',
+ taxAmount: '0.00',
+ currencyCode: 'EUR',
+ serviceDate: new Date(`${date}T00:00:00.000Z`),
+ isReversal: false,
+ originalChargeId: null,
+ parentChargeId: baseId,
+ sourceKey: null,
+ isLocked: options.locked ?? false,
+ lockedByAuditDate: options.locked ? date : null,
+ },
+ ];
+}
+
+function makeTx(
+ ledger: Array>,
+ serviceRows: Array> = [],
+ folioReservationId = RESERVATION,
+ completedAudits: Array<{ businessDate: string }> = [],
+ propertyTimezone = 'UTC',
+) {
+ const inserted: Array> = [];
+ let selectCount = 0;
+ const select = vi.fn(() => {
+ const stages = [[{
+ id: FOLIO,
+ propertyId: PROPERTY,
+ reservationId: folioReservationId,
+ status: 'open',
+ currencyCode: 'EUR',
+ }], serviceRows, ledger, [{ id: PROPERTY, timezone: propertyTimezone }], completedAudits];
+ const rows = stages[selectCount++ % stages.length]!;
+ const chain: any = {
+ from: vi.fn(() => chain),
+ where: vi.fn(() => chain),
+ for: vi.fn(async () => structuredClone(rows)),
+ then: (resolve: (value: unknown) => unknown, reject: (error: unknown) => unknown) =>
+ Promise.resolve(structuredClone(rows)).then(resolve, reject),
+ };
+ return chain;
+ });
+ const insert = vi.fn(() => ({
+ values: vi.fn((value: Record) => ({
+ returning: vi.fn(async () => {
+ const row = { id: `inserted-${inserted.length + 1}`, ...structuredClone(value) };
+ inserted.push(row);
+ ledger.push(row);
+ return [row];
+ }),
+ })),
+ }));
+ return { tx: { select, insert }, inserted };
+}
+
+function service() {
+ return new FolioService({} as any, { emit: vi.fn() } as any, {} as any);
+}
+
+describe('FolioService accepted-pricing stay amendment reconciliation', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+ it('rejects a same-property folio linked to a different reservation', async () => {
+ const { tx, inserted } = makeTx([], [], 'different-reservation');
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ await expect(folio.reconcileAcceptedStayAmendment({
+ tx,
+ propertyId: PROPERTY,
+ folioId: FOLIO,
+ reservationId: RESERVATION,
+ amendmentId: AMENDMENT,
+ previousPricing: oldPricing,
+ newPricing: oldPricing,
+ })).rejects.toBeInstanceOf(ConflictException);
+ expect(inserted).toEqual([]);
+ });
+
+ it('uses signed amendment rows for removed accepted groups and preserves extras', async () => {
+ const ledger = [
+ ...roomGroup('2026-10-01', 'one'),
+ ...roomGroup('2026-10-02', 'two'),
+ {
+ id: 'minibar-1',
+ propertyId: PROPERTY,
+ folioId: FOLIO,
+ type: 'minibar',
+ description: 'Minibar',
+ amount: '25.00',
+ taxAmount: '0.00',
+ currencyCode: 'EUR',
+ serviceDate: new Date('2026-10-01T12:00:00.000Z'),
+ isReversal: false,
+ originalChargeId: null,
+ parentChargeId: null,
+ sourceKey: null,
+ isLocked: false,
+ },
+ ];
+ const nextPricing: AcceptedPricingSnapshot = {
+ ...structuredClone(oldPricing),
+ source: 'prior',
+ grandTotal: '110.00',
+ roomTotal: '100.00',
+ taxTotal: '10.00',
+ nights: [oldPricing.nights[0]!],
+ };
+ const { tx, inserted } = makeTx(ledger);
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ const result = await folio.reconcileAcceptedStayAmendment({
+ tx,
+ propertyId: PROPERTY,
+ folioId: FOLIO,
+ reservationId: RESERVATION,
+ amendmentId: AMENDMENT,
+ previousPricing: oldPricing,
+ newPricing: nextPricing,
+ postedBy: 'staff-1',
+ });
+
+ expect(result).toEqual({
+ reversedChargeIds: [],
+ adjustmentAmount: '-110.00',
+ });
+ expect(inserted).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ type: 'room',
+ amount: '-100.00',
+ isReversal: false,
+ adjustsChargeId: 'room-two',
+ }),
+ expect.objectContaining({
+ type: 'tax',
+ amount: '-10.00',
+ isReversal: false,
+ adjustsChargeId: 'tax-two',
+ }),
+ ]));
+ expect(inserted.some((row) => row.originalChargeId === 'room-one')).toBe(false);
+ expect(inserted.some((row) => row.originalChargeId === 'minibar-1')).toBe(false);
+ });
+
+ it('posts separate room and tax corrections for changed overlap and leaves future nights for night audit', async () => {
+ const ledger = [...roomGroup('2026-10-01', 'one')];
+ const nextPricing: AcceptedPricingSnapshot = {
+ ...structuredClone(oldPricing),
+ source: 'current',
+ grandTotal: '264.00',
+ roomTotal: '240.00',
+ taxTotal: '24.00',
+ nights: [
+ { date: '2026-10-01', roomAmount: '120.00', taxAmount: '12.00' },
+ { date: '2026-10-02', roomAmount: '120.00', taxAmount: '12.00' },
+ ],
+ };
+ const { tx, inserted } = makeTx(ledger);
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ const result = await folio.reconcileAcceptedStayAmendment({
+ tx,
+ propertyId: PROPERTY,
+ folioId: FOLIO,
+ reservationId: RESERVATION,
+ amendmentId: AMENDMENT,
+ previousPricing: oldPricing,
+ newPricing: nextPricing,
+ postedBy: 'staff-1',
+ });
+
+ expect(result).toEqual({ reversedChargeIds: [], adjustmentAmount: '22.00' });
+ expect(inserted).toEqual([
+ expect.objectContaining({
+ type: 'room',
+ amount: '20.00',
+ parentChargeId: 'room-one',
+ adjustsChargeId: 'room-one',
+ isReversal: false,
+ }),
+ expect.objectContaining({
+ type: 'tax',
+ amount: '2.00',
+ parentChargeId: 'room-one',
+ adjustsChargeId: 'tax-one',
+ isReversal: false,
+ }),
+ ]);
+ expect(inserted.some((row) => row.type === 'adjustment')).toBe(false);
+ });
+
+ it('replays component corrections without posting the same revenue twice', async () => {
+ const ledger = [...roomGroup('2026-10-01', 'one')];
+ const nextPricing: AcceptedPricingSnapshot = {
+ ...structuredClone(oldPricing),
+ source: 'current',
+ grandTotal: '264.00',
+ roomTotal: '240.00',
+ taxTotal: '24.00',
+ nights: [
+ { date: '2026-10-01', roomAmount: '120.00', taxAmount: '12.00' },
+ { date: '2026-10-02', roomAmount: '120.00', taxAmount: '12.00' },
+ ],
+ };
+ const { tx, inserted } = makeTx(ledger);
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ const first = await folio.reconcileAcceptedStayAmendment({
+ tx,
+ propertyId: PROPERTY,
+ folioId: FOLIO,
+ reservationId: RESERVATION,
+ amendmentId: AMENDMENT,
+ previousPricing: oldPricing,
+ newPricing: nextPricing,
+ });
+ const insertedAfterFirst = inserted.length;
+ const replay = await folio.reconcileAcceptedStayAmendment({
+ tx,
+ propertyId: PROPERTY,
+ folioId: FOLIO,
+ reservationId: RESERVATION,
+ amendmentId: AMENDMENT,
+ previousPricing: oldPricing,
+ newPricing: nextPricing,
+ });
+
+ expect(first).toEqual({ reversedChargeIds: [], adjustmentAmount: '22.00' });
+ expect(replay).toEqual({ reversedChargeIds: [], adjustmentAmount: '0.00' });
+ expect(inserted).toHaveLength(insertedAfterFirst);
+ });
+
+ it('preserves accepted service groups that do not belong to the amended reservation', async () => {
+ const ledger = [
+ ...roomGroup('2026-10-01', 'one'),
+ {
+ id: 'other-service-charge',
+ propertyId: PROPERTY,
+ folioId: FOLIO,
+ type: 'service',
+ description: 'Transferred accepted service',
+ amount: '25.00',
+ taxAmount: '0.00',
+ currencyCode: 'EUR',
+ serviceDate: new Date('2026-10-01T00:00:00.000Z'),
+ isReversal: false,
+ originalChargeId: null,
+ parentChargeId: null,
+ sourceKey: 'accepted-pricing:reservation-service:other-row:once:2026-10-01',
+ isLocked: false,
+ },
+ ];
+ const nextPricing: AcceptedPricingSnapshot = {
+ ...structuredClone(oldPricing),
+ nights: [oldPricing.nights[0]!],
+ roomTotal: '100.00',
+ taxTotal: '10.00',
+ grandTotal: '110.00',
+ };
+ const { tx, inserted } = makeTx(ledger);
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ await folio.reconcileAcceptedStayAmendment({
+ tx,
+ propertyId: PROPERTY,
+ folioId: FOLIO,
+ reservationId: RESERVATION,
+ amendmentId: AMENDMENT,
+ previousPricing: oldPricing,
+ newPricing: nextPricing,
+ });
+
+ expect(inserted.some((row) => row.originalChargeId === 'other-service-charge')).toBe(false);
+ });
+
+ it('preserves room and tax attribution when correcting a locked removed group', async () => {
+ const ledger = [
+ ...roomGroup('2026-10-01', 'one'),
+ ...roomGroup('2026-10-02', 'two', { locked: true }),
+ ];
+ const nextPricing: AcceptedPricingSnapshot = {
+ ...structuredClone(oldPricing),
+ source: 'prior',
+ grandTotal: '110.00',
+ roomTotal: '100.00',
+ taxTotal: '10.00',
+ nights: [oldPricing.nights[0]!],
+ };
+ const { tx, inserted } = makeTx(ledger);
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ const result = await folio.reconcileAcceptedStayAmendment({
+ tx,
+ propertyId: PROPERTY,
+ folioId: FOLIO,
+ reservationId: RESERVATION,
+ amendmentId: AMENDMENT,
+ previousPricing: oldPricing,
+ newPricing: nextPricing,
+ postedBy: 'staff-1',
+ });
+
+ expect(result).toEqual({ reversedChargeIds: [], adjustmentAmount: '-110.00' });
+ expect(inserted).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ type: 'room', amount: '-100.00', isReversal: false,
+ adjustsChargeId: 'room-two', serviceDate: new Date('2026-10-03T00:00:00.000Z'),
+ }),
+ expect.objectContaining({
+ type: 'tax', amount: '-10.00', isReversal: false,
+ adjustsChargeId: 'tax-two', serviceDate: new Date('2026-10-03T00:00:00.000Z'),
+ }),
+ ]));
+ expect(ledger.slice(2, 4).every((row) => row.isLocked)).toBe(true);
+ });
+
+ it('reconciles a service charge-type and tax change by category', async () => {
+ const serviceRow = {
+ id: 'rs-1', propertyId: PROPERTY, reservationId: RESERVATION, serviceId: 'svc-1',
+ };
+ const serviceBase = {
+ id: 'service-one', propertyId: PROPERTY, folioId: FOLIO,
+ type: 'parking', description: 'Parking [svc:rs-1]', amount: '15.00', taxAmount: '0.00',
+ currencyCode: 'EUR', serviceDate: new Date('2026-10-01T00:00:00.000Z'),
+ isReversal: false, originalChargeId: null, parentChargeId: null,
+ sourceKey: 'accepted-pricing:reservation-service:rs-1:once:2026-10-01', isLocked: false,
+ };
+ const ledger = [serviceBase, {
+ ...serviceBase,
+ id: 'service-tax',
+ type: 'tax',
+ description: 'Parking tax',
+ amount: '2.00',
+ parentChargeId: 'service-one',
+ sourceKey: null,
+ }];
+ const nextPricing: AcceptedPricingSnapshot = {
+ ...structuredClone(oldPricing),
+ services: [{
+ serviceId: 'svc-1', code: 'PARK', name: 'Parking', postingRule: 'once',
+ chargeType: 'spa', currencyCode: 'EUR', unitPrice: '20.00', quantity: 1,
+ lineTotal: '20.00', taxTotal: '3.00',
+ lineItems: [{ date: '2026-10-01', amount: '20.00', taxAmount: '3.00' }],
+ }],
+ servicesTotal: '20.00',
+ servicesTaxTotal: '3.00',
+ grandTotal: '243.00',
+ };
+ const { tx, inserted } = makeTx(ledger, [serviceRow]);
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ const result = await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: AMENDMENT, previousPricing: oldPricing, newPricing: nextPricing,
+ });
+
+ expect(result.adjustmentAmount).toBe('6.00');
+ expect(inserted).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ type: 'parking', amount: '-15.00', isReversal: false,
+ adjustsChargeId: 'service-one',
+ }),
+ expect.objectContaining({
+ type: 'spa', amount: '20.00', isReversal: false,
+ adjustsChargeId: 'service-one',
+ }),
+ expect.objectContaining({
+ type: 'tax', amount: '1.00', isReversal: false,
+ adjustsChargeId: 'service-tax',
+ }),
+ ]));
+ });
+
+ it('rejects an accepted automatic service without an operational reservation-service row', async () => {
+ const nextPricing: AcceptedPricingSnapshot = {
+ ...structuredClone(oldPricing),
+ services: [{
+ serviceId: 'missing-service', code: 'MISS', name: 'Missing', postingRule: 'once',
+ chargeType: 'fee', currencyCode: 'EUR', unitPrice: '20.00', quantity: 1,
+ lineTotal: '20.00', taxTotal: '2.00',
+ lineItems: [{ date: '2026-10-01', amount: '20.00', taxAmount: '2.00' }],
+ }],
+ servicesTotal: '20.00',
+ servicesTaxTotal: '2.00',
+ grandTotal: '242.00',
+ };
+ const { tx, inserted } = makeTx([]);
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ await expect(folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: AMENDMENT, previousPricing: oldPricing, newPricing: nextPricing,
+ })).rejects.toBeInstanceOf(ConflictException);
+ expect(inserted).toEqual([]);
+ });
+
+ it('balances a partially posted per-night group and defers a future once group', async () => {
+ const serviceRow = {
+ id: 'rs-1', propertyId: PROPERTY, reservationId: RESERVATION, serviceId: 'svc-1',
+ };
+ const nightlyBase = {
+ id: 'nightly-service', propertyId: PROPERTY, folioId: FOLIO,
+ type: 'parking', description: 'Parking [svc:rs-1]', amount: '15.00', taxAmount: '0.00',
+ currencyCode: 'EUR', serviceDate: new Date('2026-10-01T00:00:00.000Z'),
+ isReversal: false, originalChargeId: null, parentChargeId: null,
+ sourceKey: 'accepted-pricing:reservation-service:rs-1:night:2026-10-01', isLocked: false,
+ };
+ const ledger = [nightlyBase, {
+ ...nightlyBase, id: 'nightly-tax', type: 'tax', description: 'Parking tax',
+ amount: '2.00', parentChargeId: nightlyBase.id, sourceKey: null,
+ }];
+ const nextPricing: AcceptedPricingSnapshot = {
+ ...structuredClone(oldPricing),
+ services: [{
+ serviceId: 'svc-1', code: 'PARK', name: 'Parking', postingRule: 'once',
+ chargeType: 'parking', currencyCode: 'EUR', unitPrice: '25.00', quantity: 1,
+ lineTotal: '25.00', taxTotal: '3.00',
+ lineItems: [{ date: '2026-10-01', amount: '25.00', taxAmount: '3.00' }],
+ }],
+ servicesTotal: '25.00', servicesTaxTotal: '3.00', grandTotal: '248.00',
+ };
+ const { tx, inserted } = makeTx(ledger, [serviceRow]);
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ const result = await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: AMENDMENT, previousPricing: oldPricing, newPricing: nextPricing,
+ });
+
+ expect(inserted).toEqual(expect.arrayContaining([
+ expect.objectContaining({ type: 'parking', amount: '-15.00', isReversal: false }),
+ expect.objectContaining({ type: 'tax', amount: '-2.00', isReversal: false }),
+ ]));
+ expect(inserted.some((row) =>
+ row.sourceKey === 'accepted-pricing:reservation-service:rs-1:once:2026-10-01')).toBe(false);
+ });
+
+ it('balances an old once date and recovers a reanchored closed once date exactly once', async () => {
+ const serviceRow = {
+ id: 'rs-1', propertyId: PROPERTY, reservationId: RESERVATION, serviceId: 'svc-1',
+ };
+ const oldBase = {
+ id: 'service-old', propertyId: PROPERTY, folioId: FOLIO,
+ type: 'parking', description: 'Parking [svc:rs-1]', amount: '20.00', taxAmount: '0.00',
+ currencyCode: 'EUR', serviceDate: new Date('2026-10-01T00:00:00.000Z'),
+ isReversal: false, originalChargeId: null, parentChargeId: null,
+ sourceKey: 'accepted-pricing:reservation-service:rs-1:once:2026-10-01',
+ isLocked: true, lockedByAuditDate: '2026-10-02',
+ };
+ const ledger = [oldBase, {
+ ...oldBase, id: 'service-old-tax', type: 'tax', description: 'Parking tax', amount: '2.00',
+ parentChargeId: oldBase.id, sourceKey: null,
+ }];
+ const reanchored: AcceptedPricingSnapshot = {
+ ...structuredClone(oldPricing),
+ nights: [], roomTotal: '0.00', taxTotal: '0.00',
+ services: [{
+ serviceId: 'svc-1', code: 'PARK', name: 'Parking', postingRule: 'once',
+ chargeType: 'parking', currencyCode: 'EUR', unitPrice: '20.00', quantity: 1,
+ lineTotal: '20.00', taxTotal: '2.00',
+ lineItems: [{ date: '2026-10-02', amount: '20.00', taxAmount: '2.00' }],
+ }],
+ servicesTotal: '20.00', servicesTaxTotal: '2.00', grandTotal: '22.00',
+ };
+ const { tx, inserted } = makeTx(
+ ledger, [serviceRow], RESERVATION, [{ businessDate: '2026-10-02' }],
+ );
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ const first = await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: AMENDMENT, previousPricing: oldPricing, newPricing: reanchored,
+ });
+ const count = inserted.length;
+ const replay = await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: AMENDMENT, previousPricing: oldPricing, newPricing: reanchored,
+ });
+
+ expect(first.adjustmentAmount).toBe('0.00');
+ expect(replay.adjustmentAmount).toBe('0.00');
+ expect(inserted).toHaveLength(count);
+ expect(inserted).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ amount: '-20.00', isReversal: false, adjustsChargeId: 'service-old',
+ serviceDate: new Date('2026-10-03T00:00:00.000Z'),
+ }),
+ expect.objectContaining({
+ amount: '-2.00', isReversal: false, adjustsChargeId: 'service-old-tax',
+ serviceDate: new Date('2026-10-03T00:00:00.000Z'),
+ }),
+ expect.objectContaining({
+ amount: '20.00',
+ sourceKey: 'accepted-pricing:reservation-service:rs-1:once:2026-10-02',
+ serviceDate: new Date('2026-10-03T00:00:00.000Z'),
+ }),
+ ]));
+ });
+
+ it('keeps repeated repricing oscillations as additive non-reversal history', async () => {
+ const ledger = [...roomGroup('2026-10-01', 'one')];
+ const { tx, inserted } = makeTx(ledger);
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+ const snapshot = (room: string, tax: string): AcceptedPricingSnapshot => ({
+ ...structuredClone(oldPricing),
+ nights: [{ date: '2026-10-01', roomAmount: room, taxAmount: tax }],
+ roomTotal: room,
+ taxTotal: tax,
+ grandTotal: new Decimal(room).plus(tax).toFixed(2),
+ });
+ let prior = snapshot('100.00', '10.00');
+ for (const [index, [room, tax]] of [
+ ['120.00', '12.00'],
+ ['80.00', '8.00'],
+ ['100.00', '10.00'],
+ ['70.00', '7.00'],
+ ].entries()) {
+ const next = snapshot(room, tax);
+ await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: `amendment-${index + 1}`, previousPricing: prior, newPricing: next,
+ });
+ prior = next;
+ }
+
+ expect(inserted.every((row) => row.isReversal === false)).toBe(true);
+ expect(inserted.every((row) => row.adjustsChargeId === 'room-one'
+ || row.adjustsChargeId === 'tax-one')).toBe(true);
+ const roomNet = ledger.filter((row) => row.type === 'room')
+ .reduce((total, row) => total.plus(row.amount), new Decimal(0));
+ const taxNet = ledger.filter((row) => row.type === 'tax')
+ .reduce((total, row) => total.plus(row.amount), new Decimal(0));
+ expect(roomNet.toFixed(2)).toBe('70.00');
+ expect(taxNet.toFixed(2)).toBe('7.00');
+ });
+
+ it('keeps exact 100 to 120 to removed room and tax history additive', async () => {
+ const ledger = [...roomGroup('2026-10-01', 'one')];
+ const { tx, inserted } = makeTx(ledger);
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+ const snapshot = (room: string, tax: string): AcceptedPricingSnapshot => ({
+ ...structuredClone(oldPricing),
+ nights: room === '0.00' && tax === '0.00'
+ ? []
+ : [{ date: '2026-10-01', roomAmount: room, taxAmount: tax }],
+ roomTotal: room,
+ taxTotal: tax,
+ grandTotal: new Decimal(room).plus(tax).toFixed(2),
+ });
+ const raised = snapshot('120.00', '12.00');
+ await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: 'raise', previousPricing: snapshot('100.00', '10.00'), newPricing: raised,
+ });
+ await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: 'remove', previousPricing: raised, newPricing: snapshot('0.00', '0.00'),
+ });
+
+ expect(inserted.map((row) => [row.type, row.amount])).toEqual([
+ ['room', '20.00'],
+ ['tax', '2.00'],
+ ['room', '-120.00'],
+ ['tax', '-12.00'],
+ ]);
+ expect(ledger.reduce(
+ (total, row) => total.plus(row.amount),
+ new Decimal(0),
+ ).toFixed(2)).toBe('0.00');
+ expect(inserted.every((row) => row.isReversal === false)).toBe(true);
+ });
+
+ it('keeps a tax-only repricing correction separate from room revenue', async () => {
+ const ledger = [...roomGroup('2026-10-01', 'one')];
+ const { tx, inserted } = makeTx(ledger);
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+ const snapshot = (tax: string): AcceptedPricingSnapshot => ({
+ ...structuredClone(oldPricing),
+ nights: [{ date: '2026-10-01', roomAmount: '100.00', taxAmount: tax }],
+ roomTotal: '100.00', taxTotal: tax,
+ grandTotal: new Decimal(100).plus(tax).toFixed(2),
+ });
+ await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: 'tax-raise', previousPricing: snapshot('10.00'), newPricing: snapshot('12.00'),
+ });
+ await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: 'tax-drop', previousPricing: snapshot('12.00'), newPricing: snapshot('7.00'),
+ });
+
+ expect(inserted.map((row) => [row.type, row.amount, row.adjustsChargeId])).toEqual([
+ ['tax', '2.00', 'tax-one'],
+ ['tax', '-5.00', 'tax-one'],
+ ]);
+ expect(ledger.filter((row) => row.type === 'room')
+ .reduce((total, row) => total.plus(row.amount), new Decimal(0)).toFixed(2)).toBe('100.00');
+ expect(ledger.filter((row) => row.type === 'tax')
+ .reduce((total, row) => total.plus(row.amount), new Decimal(0)).toFixed(2)).toBe('7.00');
+ });
+
+ it('posts a newly added closed night immediately with its canonical source and replays cleanly', async () => {
+ const ledger = [...roomGroup('2026-10-01', 'one')];
+ const { tx, inserted } = makeTx(
+ ledger,
+ [],
+ RESERVATION,
+ [{ businessDate: '2026-10-02' }],
+ );
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ const result = await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: AMENDMENT, previousPricing: oldPricing, newPricing: oldPricing,
+ });
+ const count = inserted.length;
+ await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: AMENDMENT, previousPricing: oldPricing, newPricing: oldPricing,
+ });
+
+ expect(inserted).toHaveLength(count);
+ expect(result).toEqual({ reversedChargeIds: [], adjustmentAmount: '110.00' });
+ expect(inserted).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ type: 'room', amount: '100.00',
+ sourceKey: `accepted-pricing:reservation:${RESERVATION}:night:2026-10-02`,
+ serviceDate: new Date('2026-10-03T00:00:00.000Z'),
+ }),
+ expect.objectContaining({
+ type: 'tax', amount: '10.00', serviceDate: new Date('2026-10-03T00:00:00.000Z'),
+ }),
+ ]));
+ });
+
+ it('does not duplicate a closed accepted service across a cancelled row and an active duplicate', async () => {
+ const acceptedService = {
+ serviceId: 'svc-1', code: 'PARK', name: 'Parking', postingRule: 'once' as const,
+ chargeType: 'parking', currencyCode: 'EUR', unitPrice: '20.00', quantity: 1,
+ lineTotal: '20.00', taxTotal: '2.00',
+ lineItems: [{ date: '2026-10-02', amount: '20.00', taxAmount: '2.00' }],
+ };
+ const pricing: AcceptedPricingSnapshot = {
+ version: 1, source: 'current', currencyCode: 'EUR', grandTotal: '22.00',
+ roomTotal: '0.00', taxTotal: '0.00', nights: [], services: [acceptedService],
+ servicesTotal: '20.00', servicesTaxTotal: '2.00', customReason: null, adjustment: null,
+ };
+ const serviceRows = [{
+ id: 'rs-accepted-cancelled', propertyId: PROPERTY, reservationId: RESERVATION,
+ serviceId: 'svc-1', status: 'cancelled', sourceChannel: 'booking_engine',
+ createdAt: new Date('2026-08-24T10:05:00.000Z'),
+ }, {
+ id: 'rs-frontdesk-active', propertyId: PROPERTY, reservationId: RESERVATION,
+ serviceId: 'svc-1', status: 'confirmed', sourceChannel: 'front_desk',
+ createdAt: new Date('2026-08-25T10:05:00.000Z'),
+ }];
+ const manualExtra = {
+ id: 'manual-extra', propertyId: PROPERTY, folioId: FOLIO,
+ type: 'parking', description: 'Front desk parking [svc:rs-frontdesk-active]',
+ amount: '27.00', taxAmount: '0.00', currencyCode: 'EUR',
+ serviceDate: new Date('2026-10-02T00:00:00.000Z'), isReversal: false,
+ originalChargeId: null, parentChargeId: null, sourceKey: null, isLocked: true,
+ };
+ const ledger: Array> = [manualExtra];
+ const { tx, inserted } = makeTx(
+ ledger,
+ serviceRows,
+ RESERVATION,
+ [{ businessDate: '2026-10-02' }],
+ );
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: AMENDMENT, previousPricing: pricing, newPricing: pricing,
+ });
+
+ expect(inserted.filter((row) => row.sourceKey?.includes('reservation-service'))).toEqual([]);
+ expect(ledger).toContain(manualExtra);
+ expect(inserted.some((row) => row.adjustsChargeId === manualExtra.id)).toBe(false);
+ });
+
+ it('posts a correction on the property-local open date across a UTC boundary without a completed audit', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-10-02T01:00:00.000Z'));
+ const oldNight: AcceptedPricingSnapshot = {
+ ...structuredClone(oldPricing),
+ grandTotal: '110.00', roomTotal: '100.00', taxTotal: '10.00',
+ nights: [{ date: '2026-09-30', roomAmount: '100.00', taxAmount: '10.00' }],
+ };
+ const next: AcceptedPricingSnapshot = {
+ ...structuredClone(oldNight), grandTotal: '0.00', roomTotal: '0.00', taxTotal: '0.00', nights: [],
+ };
+ const ledger = roomGroup('2026-09-30', 'timezone', { locked: true });
+ ledger.forEach((row) => { row.lockedByAuditDate = null; });
+ const { tx, inserted } = makeTx(
+ ledger,
+ [],
+ RESERVATION,
+ [],
+ 'America/Los_Angeles',
+ );
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: AMENDMENT, previousPricing: oldNight, newPricing: next,
+ });
+
+ expect(inserted).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ type: 'room', amount: '-100.00', serviceDate: new Date('2026-10-01T00:00:00.000Z'),
+ }),
+ ]));
+ });
+
+ it('uses the actual property-local date when the last completed audit is delayed', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-10-01T12:30:00.000Z'));
+ const oldNight: AcceptedPricingSnapshot = {
+ ...structuredClone(oldPricing),
+ grandTotal: '110.00', roomTotal: '100.00', taxTotal: '10.00',
+ nights: [{ date: '2026-09-20', roomAmount: '100.00', taxAmount: '10.00' }],
+ };
+ const next: AcceptedPricingSnapshot = {
+ ...structuredClone(oldNight), grandTotal: '0.00', roomTotal: '0.00', taxTotal: '0.00', nights: [],
+ };
+ const ledger = roomGroup('2026-09-20', 'delayed');
+ const { tx, inserted } = makeTx(
+ ledger,
+ [],
+ RESERVATION,
+ [{ businessDate: '2026-09-20' }],
+ 'Pacific/Kiritimati',
+ );
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: AMENDMENT, previousPricing: oldNight, newPricing: next,
+ });
+
+ expect(inserted).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ type: 'room', amount: '-100.00', serviceDate: new Date('2026-10-02T00:00:00.000Z'),
+ }),
+ ]));
+ });
+
+ it('links removal of a negative custom component to that exact component on a closed date', async () => {
+ const ledger = roomGroup('2026-10-02', 'custom', { locked: true });
+ ledger.push({
+ ...ledger[0],
+ id: 'custom-discount',
+ type: 'adjustment',
+ description: 'Accepted price adjustment: loyalty discount',
+ amount: '-20.00',
+ parentChargeId: 'room-custom',
+ sourceKey: null,
+ });
+ const previous: AcceptedPricingSnapshot = {
+ ...structuredClone(oldPricing),
+ grandTotal: '90.00', roomTotal: '100.00', taxTotal: '10.00',
+ nights: [{ date: '2026-10-02', roomAmount: '100.00', taxAmount: '10.00' }],
+ adjustment: {
+ amount: '-20.00', reason: 'loyalty discount', serviceDate: '2026-10-02',
+ },
+ };
+ const next: AcceptedPricingSnapshot = {
+ ...structuredClone(previous), grandTotal: '110.00', adjustment: null,
+ };
+ const { tx, inserted } = makeTx(ledger);
+ const folio = service();
+ vi.spyOn(folio, 'recalculateBalance').mockResolvedValue(undefined);
+
+ await folio.reconcileAcceptedStayAmendment({
+ tx, propertyId: PROPERTY, folioId: FOLIO, reservationId: RESERVATION,
+ amendmentId: AMENDMENT, previousPricing: previous, newPricing: next,
+ });
+
+ expect(inserted).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ type: 'adjustment',
+ amount: '20.00',
+ adjustsChargeId: 'custom-discount',
+ serviceDate: new Date('2026-10-03T00:00:00.000Z'),
+ description: expect.stringContaining('affected 2026-10-02'),
+ }),
+ ]));
+ });
+});
diff --git a/apps/api/src/modules/folio/folio.service.spec.ts b/apps/api/src/modules/folio/folio.service.spec.ts
index 1833c949..f09aeafe 100644
--- a/apps/api/src/modules/folio/folio.service.spec.ts
+++ b/apps/api/src/modules/folio/folio.service.spec.ts
@@ -4,6 +4,7 @@ import { FolioService } from './folio.service';
import { WebhookService } from '../webhook/webhook.service';
import { TaxService } from '../tax/tax.service';
import { DRIZZLE } from '../../database/database.module';
+import { charges, folios, payments } from '@telivityhaip/database';
const mockFolio = {
id: 'folio-001',
@@ -74,6 +75,19 @@ function createMockDb(returnData: any[] = [mockFolio]) {
const mockWebhookService = { emit: vi.fn() };
const mockTaxService = { calculateTaxes: vi.fn().mockResolvedValue([]) };
+function sqlPredicateParts(value: any, parts = {
+ columns: [] as string[],
+ params: [] as unknown[],
+}) {
+ if (!value || typeof value !== 'object') return parts;
+ if (typeof value.name === 'string') parts.columns.push(value.name);
+ if (value.constructor?.name === 'Param') parts.params.push(value.value);
+ if (Array.isArray(value.queryChunks)) {
+ for (const chunk of value.queryChunks) sqlPredicateParts(chunk, parts);
+ }
+ return parts;
+}
+
describe('FolioService', () => {
let service: FolioService;
let mockDb: ReturnType;
@@ -249,6 +263,79 @@ describe('FolioService', () => {
).rejects.toThrow(BadRequestException);
});
+ it('rejects transferring an internal accepted-pricing correction', async () => {
+ let selectCallCount = 0;
+ const targetFolio = { ...mockFolio, id: 'folio-002' };
+ const correction = {
+ ...mockCharge,
+ id: 'correction-1',
+ amount: '-20.00',
+ adjustsChargeId: mockCharge.id,
+ parentChargeId: mockCharge.id,
+ };
+ const resolveRows = () => {
+ selectCallCount++;
+ if (selectCallCount === 1) return [mockFolio];
+ if (selectCallCount === 2) return [targetFolio];
+ return [correction];
+ };
+ const db: any = {
+ transaction: vi.fn(async (work: (tx: any) => Promise) => work(db)),
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ for: vi.fn(async () => resolveRows()),
+ then: (resolve: (rows: unknown[]) => unknown) => resolve(resolveRows()),
+ })),
+ })),
+ })),
+ update: vi.fn(),
+ };
+ const svc = new FolioService(db, mockWebhookService as any, mockTaxService as any);
+
+ await expect(svc.transferCharge('folio-001', 'prop-001', {
+ chargeId: correction.id,
+ targetFolioId: targetFolio.id,
+ })).rejects.toThrow(/accepted-pricing correction/i);
+ expect(db.update).not.toHaveBeenCalled();
+ });
+
+ it('rejects transferring a child of an accepted-pricing group', async () => {
+ const targetFolio = { ...mockFolio, id: 'folio-002' };
+ const base = {
+ ...mockCharge,
+ id: 'accepted-base',
+ sourceKey: 'accepted-pricing:reservation:res-1:night:2026-04-04',
+ };
+ const taxChild = {
+ ...mockCharge,
+ id: 'accepted-tax',
+ type: 'tax',
+ parentChargeId: base.id,
+ };
+ const rows = [[mockFolio], [targetFolio], [taxChild], [base]];
+ let call = 0;
+ const db: any = {
+ transaction: vi.fn(async (work: (tx: any) => Promise) => work(db)),
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ for: vi.fn(async () => rows[call++] ?? []),
+ then: (resolve: (value: unknown[]) => unknown) => resolve(rows[call++] ?? []),
+ })),
+ })),
+ })),
+ update: vi.fn(),
+ };
+ const svc = new FolioService(db, mockWebhookService as any, mockTaxService as any);
+
+ await expect(svc.transferCharge('folio-001', 'prop-001', {
+ chargeId: taxChild.id,
+ targetFolioId: targetFolio.id,
+ })).rejects.toThrow(/accepted-pricing group/i);
+ expect(db.update).not.toHaveBeenCalled();
+ });
+
it('should transfer charge between folios', async () => {
let selectCallCount = 0;
const targetFolio = { ...mockFolio, id: 'folio-002' };
@@ -364,7 +451,234 @@ describe('FolioService', () => {
});
});
+ describe('postChargeFromSnapshot', () => {
+ it('posts the frozen base, tax, and custom adjustment exactly once after commit', async () => {
+ const tx = { marker: 'snapshot-transaction' };
+ const db = {
+ transaction: vi.fn(async (callback: (transaction: unknown) => Promise) =>
+ callback(tx)),
+ };
+ const webhook = { emit: vi.fn().mockResolvedValue(undefined) };
+ const tax = { calculateTaxes: vi.fn() };
+ const snapshotService = new FolioService(db as any, webhook as any, tax as any);
+ const postCharge = vi.spyOn(snapshotService, 'postCharge').mockImplementation(async (
+ _folioId: string,
+ dto: any,
+ _tx?: unknown,
+ metadata?: { parentChargeId?: string },
+ ) => ({
+ id: `charge-${dto.type}`,
+ ...dto,
+ parentChargeId: metadata?.parentChargeId ?? null,
+ taxCharges: [],
+ }));
+
+ const result = await snapshotService.postChargeFromSnapshot(
+ 'folio-001',
+ {
+ propertyId: 'prop-001',
+ type: 'room',
+ description: 'Room tariff - 2026-04-04',
+ amount: '123.00',
+ currencyCode: 'USD',
+ serviceDate: '2026-04-04T00:00:00.000Z',
+ },
+ '12.00',
+ { amount: '-15.00', reason: 'Loyalty recovery' },
+ );
+
+ expect(db.transaction).toHaveBeenCalledOnce();
+ expect(postCharge).toHaveBeenCalledTimes(3);
+ expect(postCharge.mock.calls.map((call) => ({
+ type: call[1].type,
+ amount: call[1].amount,
+ transaction: call[2],
+ }))).toEqual([
+ { type: 'room', amount: '123.00', transaction: tx },
+ { type: 'tax', amount: '12.00', transaction: tx },
+ { type: 'adjustment', amount: '-15.00', transaction: tx },
+ ]);
+ expect(tax.calculateTaxes).not.toHaveBeenCalled();
+ expect(webhook.emit).toHaveBeenCalledTimes(3);
+ expect(result.adjustmentCharges).toHaveLength(1);
+ expect(result.taxCharges).toEqual([
+ expect.objectContaining({ parentChargeId: result.id }),
+ ]);
+ expect(result.adjustmentCharges).toEqual([
+ expect.objectContaining({ parentChargeId: result.id }),
+ ]);
+ });
+
+ it('posts one frozen base/tax group under concurrent attempts with the same source key', async () => {
+ const ledger: Array> = [];
+ let sequence = 1;
+ let transactionQueue = Promise.resolve();
+ const db: any = {
+ transaction: vi.fn(async (callback: (tx: any) => Promise) => {
+ const previous = transactionQueue;
+ let release = () => undefined;
+ transactionQueue = new Promise((resolve) => {
+ release = resolve;
+ });
+ await previous;
+ try {
+ return await callback(db);
+ } finally {
+ release();
+ }
+ }),
+ select: vi.fn((projection?: Record) => ({
+ from: vi.fn((table: unknown) => ({
+ where: vi.fn(async (predicate: unknown) => {
+ if (table === folios) return [{ ...mockFolio, status: 'open' }];
+ if (projection?.['total']) return [{ total: '0' }];
+ if (table === payments) return [{ total: '0' }];
+ const parts = sqlPredicateParts(predicate);
+ if (parts.columns.includes('source_key')) {
+ const sourceKey = parts.params.find((param) =>
+ typeof param === 'string' && param.startsWith('accepted-pricing:'));
+ return ledger.filter((row) => row.sourceKey === sourceKey);
+ }
+ if (parts.columns.includes('parent_charge_id')) {
+ const parentId = parts.params.find((param) =>
+ typeof param === 'string' && param.startsWith('charge-'));
+ return ledger.filter((row) =>
+ row.parentChargeId === parentId && !row.isReversal);
+ }
+ return [];
+ }),
+ })),
+ })),
+ insert: vi.fn((table: unknown) => ({
+ values: vi.fn((values: Record) => {
+ const insert = async (conflictSafe: boolean) => {
+ if (
+ conflictSafe
+ && values.sourceKey
+ && ledger.some((row) =>
+ row.propertyId === values.propertyId
+ && row.folioId === values.folioId
+ && row.sourceKey === values.sourceKey)
+ ) {
+ return [];
+ }
+ const row = { id: `charge-${sequence++}`, ...values };
+ if (table === charges) ledger.push(row);
+ return [row];
+ };
+ return {
+ returning: vi.fn(() => insert(false)),
+ onConflictDoNothing: vi.fn(() => ({
+ returning: vi.fn(() => insert(true)),
+ })),
+ };
+ }),
+ })),
+ update: vi.fn(() => ({
+ set: vi.fn(() => ({ where: vi.fn(async () => []) })),
+ })),
+ };
+ const webhook = { emit: vi.fn().mockResolvedValue(undefined) };
+ const svc = new FolioService(db, webhook as any, { calculateTaxes: vi.fn() } as any);
+ const input = {
+ propertyId: 'prop-001',
+ type: 'parking',
+ description: 'Frozen parking',
+ amount: '15.00',
+ currencyCode: 'USD',
+ serviceDate: '2026-04-04T00:00:00.000Z',
+ };
+ const sourceKey = 'accepted-pricing:reservation-service:rs-1:once';
+
+ const outcomes = await Promise.all([
+ (svc as any).postChargeFromSnapshotWithOutcome(
+ 'folio-001', input, '2.00', undefined, sourceKey,
+ ),
+ (svc as any).postChargeFromSnapshotWithOutcome(
+ 'folio-001', input, '2.00', undefined, sourceKey,
+ ),
+ ]);
+
+ expect(ledger.map((row) => row.type)).toEqual(['parking', 'tax']);
+ expect(outcomes.map((outcome) => outcome.wasCreated).sort()).toEqual([false, true]);
+ expect(outcomes[0].charge.id).toBe(outcomes[1].charge.id);
+ expect(outcomes[0].charge.taxCharges).toEqual(outcomes[1].charge.taxCharges);
+ expect(webhook.emit).toHaveBeenCalledTimes(2);
+
+ const publicReplay = await svc.postChargeFromSnapshot(
+ 'folio-001', input as any, '2.00', undefined, sourceKey,
+ );
+ expect(publicReplay).not.toHaveProperty('wasCreated');
+ expect(JSON.parse(JSON.stringify(publicReplay))).not.toHaveProperty('wasCreated');
+ });
+ });
+
describe('reverseCharge', () => {
+ it('rejects reversing an internal accepted-pricing correction', async () => {
+ const correction = {
+ ...mockCharge,
+ id: 'correction-1',
+ amount: '-20.00',
+ adjustsChargeId: mockCharge.id,
+ parentChargeId: mockCharge.id,
+ };
+ const db: any = {
+ transaction: vi.fn(async (work: (tx: any) => Promise) => work(db)),
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ for: vi.fn(async () => [correction]),
+ then: (resolve: (rows: unknown[]) => unknown) => resolve([correction]),
+ })),
+ })),
+ })),
+ insert: vi.fn(),
+ };
+ const svc = new FolioService(db, mockWebhookService as any, mockTaxService as any);
+
+ await expect(
+ svc.reverseCharge('folio-001', correction.id, 'prop-001'),
+ ).rejects.toThrow(/accepted-pricing correction/i);
+ expect(db.insert).not.toHaveBeenCalled();
+ });
+
+ it('requires an accepted-pricing group reversal to start from its canonical base', async () => {
+ const base = {
+ ...mockCharge,
+ id: 'accepted-base',
+ sourceKey: 'accepted-pricing:reservation:res-1:night:2026-04-04',
+ };
+ const taxChild = {
+ ...mockCharge,
+ id: 'accepted-tax',
+ type: 'tax',
+ amount: '10.00',
+ parentChargeId: base.id,
+ };
+ let selectCount = 0;
+ const db: any = {
+ transaction: vi.fn(async (work: (tx: any) => Promise) => work(db)),
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => {
+ const rows = selectCount++ === 0 ? [taxChild] : [base];
+ return {
+ for: vi.fn(async () => rows),
+ then: (resolve: (value: unknown[]) => unknown) => resolve(rows),
+ };
+ }),
+ })),
+ })),
+ insert: vi.fn(),
+ };
+ const svc = new FolioService(db, mockWebhookService as any, mockTaxService as any);
+
+ await expect(
+ svc.reverseCharge('folio-001', taxChild.id, 'prop-001'),
+ ).rejects.toThrow(/reverse the accepted-pricing group from its base/i);
+ expect(db.insert).not.toHaveBeenCalled();
+ });
+
it('should create a negated charge for reversal', async () => {
const reversalCharge = {
...mockCharge,
@@ -455,6 +769,112 @@ describe('FolioService', () => {
);
expect(db.insert).not.toHaveBeenCalled();
});
+
+ it('reverses frozen tax and accepted adjustment children with the base exactly once', async () => {
+ const base = {
+ ...mockCharge,
+ id: 'base-charge',
+ taxAmount: '0.00',
+ serviceDate: new Date('2026-04-04T00:00:00.000Z'),
+ sourceKey: 'accepted-pricing:reservation:res-1:night:2026-04-04',
+ };
+ const taxChild = {
+ ...base,
+ id: 'tax-child',
+ type: 'tax',
+ amount: '12.00',
+ parentChargeId: base.id,
+ };
+ const adjustmentChild = {
+ ...base,
+ id: 'adjustment-child',
+ type: 'adjustment',
+ amount: '-15.00',
+ parentChargeId: base.id,
+ adjustsChargeId: base.id,
+ };
+ const inserted: Array> = [];
+ const chargeLookupPredicates: Array<{ columns: string[]; params: unknown[] }> = [];
+ let nextId = 1;
+ const db: any = {
+ transaction: vi.fn(async (callback: (tx: any) => Promise) => callback(db)),
+ select: vi.fn((projection?: Record) => ({
+ from: vi.fn((table: unknown) => ({
+ where: vi.fn(async (predicate: unknown) => {
+ if (projection?.['total']) return [{ total: '0' }];
+ if (table === payments) return [{ total: '0' }];
+ const parts = sqlPredicateParts(predicate);
+ if (table === charges) {
+ chargeLookupPredicates.push({
+ columns: [...parts.columns],
+ params: [...parts.params],
+ });
+ }
+ if (parts.columns.includes('parent_charge_id')) {
+ const children = [taxChild, adjustmentChild];
+ return parts.params.includes('tax')
+ ? children.filter((child) => child.type === 'tax')
+ : children;
+ }
+ if (parts.columns.includes('original_charge_id')) {
+ const originalId = parts.params.find((param) =>
+ ['base-charge', 'tax-child', 'adjustment-child'].includes(String(param)));
+ return inserted.filter((row) =>
+ row.originalChargeId === originalId && row.isReversal);
+ }
+ return [base];
+ }),
+ })),
+ })),
+ insert: vi.fn(() => ({
+ values: vi.fn((values: Record) => ({
+ returning: vi.fn(async () => {
+ const row = { id: `reversal-${nextId++}`, ...values };
+ inserted.push(row);
+ return [row];
+ }),
+ })),
+ })),
+ update: vi.fn((table: unknown) => ({
+ set: vi.fn(() => ({
+ where: vi.fn(async () => table === folios ? [] : []),
+ })),
+ })),
+ };
+ const svc = new FolioService(
+ db,
+ { emit: vi.fn().mockResolvedValue(undefined) } as any,
+ { calculateTaxes: vi.fn() } as any,
+ );
+
+ await svc.reverseCharge('folio-001', base.id, 'prop-001');
+
+ expect(db.transaction).toHaveBeenCalledOnce();
+ expect(inserted.map((row) => ({
+ type: row.type,
+ originalChargeId: row.originalChargeId,
+ parentChargeId: row.parentChargeId ?? null,
+ }))).toEqual([
+ { type: 'room', originalChargeId: base.id, parentChargeId: null },
+ { type: 'tax', originalChargeId: taxChild.id, parentChargeId: 'reversal-1' },
+ {
+ type: 'adjustment',
+ originalChargeId: adjustmentChild.id,
+ parentChargeId: 'reversal-1',
+ },
+ ]);
+ await expect(
+ svc.reverseCharge('folio-001', base.id, 'prop-001'),
+ ).rejects.toThrow(/already been reversed/i);
+ expect(inserted).toHaveLength(3);
+ const signedGroupTotal = [base, taxChild, adjustmentChild, ...inserted]
+ .reduce((total, row) => total + Number(row.amount), 0);
+ expect(signedGroupTotal).toBe(0);
+ expect(chargeLookupPredicates.length).toBeGreaterThan(0);
+ expect(chargeLookupPredicates.every((predicate) =>
+ predicate.columns.includes('property_id')
+ && predicate.params.includes('prop-001'))).toBe(true);
+ });
});
describe('close', () => {
@@ -512,25 +932,40 @@ describe('FolioService', () => {
});
describe('getCharges', () => {
- it('should return paginated charges with filters', async () => {
+ function getChargesDb(
+ pageRows: any[],
+ metadataRows: any[] = [],
+ total = pageRows.length,
+ ) {
let selectCall = 0;
- const db = {
- select: vi.fn().mockImplementation(() => ({
- from: vi.fn().mockReturnValue({
- where: vi.fn().mockReturnValue({
- limit: vi.fn().mockReturnValue({
- offset: vi.fn().mockReturnValue({
- orderBy: vi.fn().mockResolvedValue([mockCharge]),
- }),
+ return {
+ select: vi.fn().mockImplementation(() => {
+ selectCall += 1;
+ const currentCall = selectCall;
+ return {
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockImplementation(() => {
+ if (currentCall === 1) {
+ return {
+ limit: vi.fn().mockReturnValue({
+ offset: vi.fn().mockReturnValue({
+ orderBy: vi.fn().mockResolvedValue(pageRows),
+ }),
+ }),
+ };
+ }
+ return Promise.resolve(
+ currentCall === 2 ? [{ count: total }] : metadataRows,
+ );
}),
- then: (resolve: any) => {
- selectCall++;
- resolve([{ count: 1 }]);
- },
}),
- }),
- })),
+ };
+ }),
};
+ }
+
+ it('should return paginated charges with filters', async () => {
+ const db = getChargesDb([mockCharge]);
const module: TestingModule = await Test.createTestingModule({
providers: [
FolioService,
@@ -547,10 +982,101 @@ describe('FolioService', () => {
page: 1,
limit: 10,
});
- expect(result.data).toEqual([mockCharge]);
+ expect(result.data).toEqual([{
+ ...mockCharge,
+ canMove: true,
+ canReverse: true,
+ }]);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
});
+
+ it('marks a paginated accepted-pricing child non-operable from its off-page base', async () => {
+ const taxChild = {
+ ...mockCharge,
+ id: 'tax-child-on-page-two',
+ type: 'tax',
+ parentChargeId: 'accepted-base-on-page-one',
+ };
+ const db = getChargesDb([taxChild], [{
+ id: taxChild.parentChargeId,
+ sourceKey: 'accepted-pricing:reservation:res-1:night:2026-06-02',
+ }], 21);
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ FolioService,
+ { provide: DRIZZLE, useValue: db },
+ { provide: WebhookService, useValue: mockWebhookService },
+ { provide: TaxService, useValue: mockTaxService },
+ ],
+ }).compile();
+
+ const result = await module.get(FolioService).getCharges('folio-001', {
+ propertyId: 'prop-001',
+ page: 2,
+ limit: 20,
+ });
+
+ expect(result.data).toEqual([{
+ ...taxChild,
+ canMove: false,
+ canReverse: false,
+ }]);
+ });
+
+ it('preserves generic tax-child operations when its off-page base is not accepted pricing', async () => {
+ const taxChild = {
+ ...mockCharge,
+ id: 'generic-tax-child-on-page-two',
+ type: 'tax',
+ parentChargeId: 'generic-base-on-page-one',
+ };
+ const db = getChargesDb([taxChild], [{
+ id: taxChild.parentChargeId,
+ sourceKey: null,
+ }], 21);
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ FolioService,
+ { provide: DRIZZLE, useValue: db },
+ { provide: WebhookService, useValue: mockWebhookService },
+ { provide: TaxService, useValue: mockTaxService },
+ ],
+ }).compile();
+
+ const result = await module.get(FolioService).getCharges('folio-001', {
+ propertyId: 'prop-001',
+ page: 2,
+ limit: 20,
+ });
+
+ expect(result.data[0]).toMatchObject({ canMove: true, canReverse: true });
+ });
+
+ it('marks an original non-reversible when its reversal is outside the current page', async () => {
+ const original = { ...mockCharge, id: 'original-on-page-two' };
+ const db = getChargesDb([original], [{
+ id: 'reversal-on-page-one',
+ isReversal: true,
+ originalChargeId: original.id,
+ }], 21);
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ FolioService,
+ { provide: DRIZZLE, useValue: db },
+ { provide: WebhookService, useValue: mockWebhookService },
+ { provide: TaxService, useValue: mockTaxService },
+ ],
+ }).compile();
+
+ const result = await module.get(FolioService).getCharges('folio-001', {
+ propertyId: 'prop-001',
+ page: 2,
+ limit: 20,
+ });
+
+ expect(result.data[0]).toMatchObject({ canReverse: false });
+ });
});
describe('lockCharges', () => {
diff --git a/apps/api/src/modules/folio/folio.service.ts b/apps/api/src/modules/folio/folio.service.ts
index f399d584..081d7281 100644
--- a/apps/api/src/modules/folio/folio.service.ts
+++ b/apps/api/src/modules/folio/folio.service.ts
@@ -3,11 +3,24 @@ import {
Inject,
NotFoundException,
BadRequestException,
+ ConflictException,
} from '@nestjs/common';
-import { eq, and, sql, gte, lte } from 'drizzle-orm';
+import { eq, and, or, inArray, sql, gte, lte } from 'drizzle-orm';
import Decimal from 'decimal.js';
-import { folios, charges, payments, reservations, bookings } from '@telivityhaip/database';
+import {
+ folios,
+ charges,
+ payments,
+ reservations,
+ bookings,
+ reservationServices,
+ auditRuns,
+ properties,
+} from '@telivityhaip/database';
+import type { AcceptedPricingSnapshot } from '@telivityhaip/database';
import { DRIZZLE } from '../../database/database.module';
+import { matchAcceptedReservationServiceRows } from '../../common/accepted-pricing/accepted-reservation-service';
+import { calendarDateInTimeZone } from '../../common/date/property-business-date';
import { folioPaymentSumWhere } from '../payment/payment-ledger';
import { WebhookService } from '../webhook/webhook.service';
import { TaxService } from '../tax/tax.service';
@@ -18,6 +31,19 @@ import { TransferChargeDto } from './dto/transfer-charge.dto';
import { CreateChargeDto } from './dto/create-charge.dto';
import { ListChargesDto } from './dto/list-charges.dto';
+const CHARGE_WAS_CREATED = Symbol('chargeWasCreated');
+
+type AcceptedStayAmendmentReconciliationInput = {
+ tx: any;
+ propertyId: string;
+ folioId: string;
+ reservationId: string;
+ amendmentId: string;
+ previousPricing: AcceptedPricingSnapshot;
+ newPricing: AcceptedPricingSnapshot;
+ postedBy?: string | null;
+};
+
@Injectable()
export class FolioService {
constructor(
@@ -51,13 +77,15 @@ export class FolioService {
.insert(folios)
.values({ ...dto, folioNumber })
.returning();
- await this.webhookService.emit(
- 'folio.created',
- 'folio',
- folio.id,
- { folioNumber: folio.folioNumber, type: folio.type },
- folio.propertyId,
- );
+ if (!tx) {
+ await this.webhookService.emit(
+ 'folio.created',
+ 'folio',
+ folio.id,
+ { folioNumber: folio.folioNumber, type: folio.type },
+ folio.propertyId,
+ );
+ }
return folio;
}
@@ -243,6 +271,33 @@ export class FolioService {
if (charge.isLocked) {
throw new BadRequestException('Cannot transfer a locked charge');
}
+ if (charge.adjustsChargeId) {
+ throw new BadRequestException(
+ 'Cannot transfer an internal accepted-pricing correction',
+ );
+ }
+ if (typeof charge.sourceKey === 'string'
+ && charge.sourceKey.startsWith('accepted-pricing:')) {
+ throw new BadRequestException(
+ 'Cannot transfer an accepted-pricing group individually',
+ );
+ }
+ if (charge.parentChargeId) {
+ const [parent] = await tx
+ .select()
+ .from(charges)
+ .where(and(
+ eq(charges.id, charge.parentChargeId),
+ eq(charges.folioId, folioId),
+ eq(charges.propertyId, propertyId),
+ ));
+ if (typeof parent?.sourceKey === 'string'
+ && parent.sourceKey.startsWith('accepted-pricing:')) {
+ throw new BadRequestException(
+ 'Cannot transfer a child of an accepted-pricing group individually',
+ );
+ }
+ }
await tx
.update(charges)
@@ -284,53 +339,408 @@ export class FolioService {
.where(and(eq(folios.id, folioId), eq(folios.propertyId, propertyId)));
}
- async postCharge(folioId: string, dto: CreateChargeDto, tx?: any) {
- const db = tx ?? this.db;
- const folio = await this.findById(folioId, dto.propertyId, tx);
+ /** Reconcile immutable accepted-pricing groups without changing unrelated folio revenue. */
+ async reconcileAcceptedStayAmendment(
+ input: AcceptedStayAmendmentReconciliationInput,
+ ): Promise<{ reversedChargeIds: string[]; adjustmentAmount: string }> {
+ const {
+ tx,
+ propertyId,
+ folioId,
+ reservationId,
+ amendmentId,
+ previousPricing,
+ newPricing,
+ postedBy,
+ } = input;
+ const folioQuery = tx
+ .select()
+ .from(folios)
+ .where(and(eq(folios.id, folioId), eq(folios.propertyId, propertyId)));
+ const [folio] = typeof folioQuery.for === 'function'
+ ? await folioQuery.for('update')
+ : await folioQuery;
+ if (!folio) throw new NotFoundException(`Folio ${folioId} not found`);
+ if (folio.reservationId !== reservationId) {
+ throw new ConflictException('The linked folio does not belong to the amended reservation');
+ }
if (folio.status !== 'open') {
- throw new BadRequestException('Cannot post charge to a folio that is not open');
+ throw new ConflictException('The linked folio must be open to amend the stay');
}
-
- // A negative/zero amount inverts or zeroes the folio balance. Only legitimate
- // credit paths may go non-positive: an explicit `adjustment` charge or a
- // reversal. Everything else must be strictly positive.
if (
- new Decimal(dto.amount).lessThanOrEqualTo(0) &&
- dto.type !== 'adjustment' &&
- !dto.isReversal
+ folio.currencyCode !== previousPricing.currencyCode
+ || folio.currencyCode !== newPricing.currencyCode
) {
- throw new BadRequestException(
- 'Charge amount must be positive (negatives are only allowed for adjustments or reversals)',
- );
+ throw new ConflictException('Amended pricing currency does not match the linked folio');
}
- // Validate originalChargeId WHENEVER supplied (not only for reversals) so a
- // caller can't attach a dangling reference to another property's charge.
- if (dto.originalChargeId) {
- const [original] = await db
+ const serviceRows = await tx
+ .select()
+ .from(reservationServices)
+ .where(and(
+ eq(reservationServices.propertyId, propertyId),
+ eq(reservationServices.reservationId, reservationId),
+ ));
+ const scopedServiceRows = serviceRows.filter((row: any) =>
+ row.propertyId === propertyId && row.reservationId === reservationId);
+ const previousServiceRows = matchAcceptedReservationServiceRows(
+ previousPricing,
+ scopedServiceRows,
+ );
+ const newServiceRows = matchAcceptedReservationServiceRows(newPricing, scopedServiceRows);
+ const acceptedServiceRowsById = new Map(
+ [...previousServiceRows.values(), ...newServiceRows.values()]
+ .map((row: any) => [row.id as string, row.serviceId as string]),
+ );
+ const chargeQuery = tx
+ .select()
+ .from(charges)
+ .where(and(eq(charges.propertyId, propertyId), eq(charges.folioId, folioId)));
+ const ledger = (typeof chargeQuery.for === 'function'
+ ? await chargeQuery.for('update')
+ : await chargeQuery)
+ .filter((row: any) => row.propertyId === propertyId && row.folioId === folioId);
+ const [property] = await tx
+ .select({ id: properties.id, timezone: properties.timezone })
+ .from(properties)
+ .where(eq(properties.id, propertyId));
+ if (!property) throw new ConflictException(`Property ${propertyId} not found`);
+ const completedAudits = await tx
+ .select({ businessDate: auditRuns.businessDate })
+ .from(auditRuns)
+ .where(and(
+ eq(auditRuns.propertyId, propertyId),
+ eq(auditRuns.status, 'completed' as any),
+ ));
+ const calendarDate = (value: unknown) => value instanceof Date
+ ? value.toISOString().slice(0, 10)
+ : typeof value === 'string'
+ ? value.slice(0, 10)
+ : null;
+ const latestClosedDate = [
+ ...completedAudits.map((row: any) => calendarDate(row.businessDate)),
+ ...ledger
+ .filter((row: any) => row.isLocked)
+ .map((row: any) => calendarDate(row.lockedByAuditDate)),
+ ]
+ .filter((value): value is string => value != null)
+ .sort()
+ .at(-1);
+ const addCalendarDay = (date: string) => {
+ const value = new Date(`${date}T00:00:00.000Z`);
+ value.setUTCDate(value.getUTCDate() + 1);
+ return value.toISOString().slice(0, 10);
+ };
+ const today = calendarDateInTimeZone(new Date(), property.timezone);
+ const currentOpenDate = latestClosedDate == null
+ ? today
+ : [today, addCalendarDay(latestClosedDate)].sort().at(-1)!;
+
+ type DesiredGroup = {
+ sourceKey: string;
+ serviceDate: string;
+ description: string;
+ baseType: string;
+ baseAmount: string;
+ taxAmount: string;
+ customAdjustment?: { amount: string; reason: string };
+ reservationServiceId?: string;
+ };
+ const desiredGroups = new Map();
+ for (const night of newPricing.nights) {
+ const sourceKey = `accepted-pricing:reservation:${reservationId}:night:${night.date}`;
+ desiredGroups.set(sourceKey, {
+ sourceKey,
+ serviceDate: night.date,
+ description: `Room tariff - ${night.date}`,
+ baseType: 'room',
+ baseAmount: night.roomAmount,
+ taxAmount: night.taxAmount,
+ customAdjustment: newPricing.adjustment?.serviceDate === night.date
+ ? {
+ amount: newPricing.adjustment.amount,
+ reason: newPricing.adjustment.reason,
+ }
+ : undefined,
+ });
+ }
+ for (const pricedService of [...previousPricing.services, ...newPricing.services]) {
+ if (
+ pricedService.postingRule !== 'on_consumption'
+ && !previousServiceRows.has(pricedService.serviceId)
+ && !newServiceRows.has(pricedService.serviceId)
+ ) {
+ throw new ConflictException(
+ `Accepted service ${pricedService.code} has no linked reservation service`,
+ );
+ }
+ }
+ for (const pricedService of newPricing.services) {
+ if (pricedService.postingRule === 'on_consumption') continue;
+ const row = newServiceRows.get(pricedService.serviceId);
+ if (!row || row.status === 'cancelled') continue;
+ const lines = pricedService.postingRule === 'per_night'
+ ? pricedService.lineItems
+ : pricedService.lineItems.slice(0, 1);
+ for (const line of lines) {
+ const suffix = pricedService.postingRule === 'per_night'
+ ? `night:${line.date}`
+ : `once:${line.date}`;
+ const sourceKey = `accepted-pricing:reservation-service:${row.id}:${suffix}`;
+ desiredGroups.set(sourceKey, {
+ sourceKey,
+ serviceDate: line.date,
+ description: `${pricedService.name} [svc:${row.id}]`.slice(0, 255),
+ baseType: pricedService.chargeType,
+ baseAmount: line.amount,
+ taxAmount: line.taxAmount,
+ reservationServiceId: row.id,
+ });
+ }
+ }
+ const acceptedBases = ledger.filter((row: any) =>
+ !row.isReversal
+ && row.parentChargeId == null
+ && typeof row.sourceKey === 'string'
+ && (
+ row.sourceKey.startsWith(`accepted-pricing:reservation:${reservationId}:night:`)
+ || (() => {
+ const match = /^accepted-pricing:reservation-service:([^:]+):/.exec(row.sourceKey);
+ return match != null && acceptedServiceRowsById.has(match[1]!);
+ })()
+ ));
+
+ const groupRows = (base: any) => {
+ const children = ledger.filter((row: any) =>
+ !row.isReversal && row.parentChargeId === base.id);
+ const originals = new Set([base.id, ...children.map((row: any) => row.id)]);
+ const reversals = ledger.filter((row: any) =>
+ row.isReversal && originals.has(row.originalChargeId));
+ return { children, reversals, all: [base, ...children, ...reversals] };
+ };
+ // This method never creates canonical reversals: repricing is represented
+ // by signed amendment rows so revenue reports retain every correction.
+ const reversedChargeIds: string[] = [];
+ const amendmentSourcePrefix = `accepted-pricing:reservation:${reservationId}:amendment:`;
+ let adjustment = new Decimal(0);
+
+ const componentKey = (row: any, base: any): string => {
+ const marker = typeof row.sourceKey === 'string'
+ ? /:component:[^:]+:(base:[^:]+|tax|custom)$/.exec(row.sourceKey)?.[1]
+ : undefined;
+ if (marker) return marker;
+ if (row.id === base.id) return `base:${row.type}`;
+ if (row.type === 'tax') return 'tax';
+ if (row.type === 'adjustment') return 'custom';
+ return `base:${row.type}`;
+ };
+ const componentTotals = (base: any, all: any[]) => {
+ const totals = new Map();
+ for (const row of all) {
+ const key = componentKey(row, base);
+ totals.set(key, (totals.get(key) ?? new Decimal(0)).plus(row.amount ?? 0));
+ if (!new Decimal(row.taxAmount ?? 0).isZero()) {
+ totals.set('tax', (totals.get('tax') ?? new Decimal(0)).plus(row.taxAmount));
+ }
+ }
+ return totals;
+ };
+ const desiredComponents = (desired?: DesiredGroup) => {
+ const totals = new Map();
+ if (!desired) return totals;
+ totals.set(`base:${desired.baseType}`, new Decimal(desired.baseAmount));
+ totals.set('tax', new Decimal(desired.taxAmount));
+ if (desired.customAdjustment) {
+ totals.set('custom', new Decimal(desired.customAdjustment.amount));
+ }
+ return totals;
+ };
+ const insert = async (
+ values: Record,
+ claimSource = false,
+ ): Promise<{ row: any; created: boolean }> => {
+ let statement: any = tx.insert(charges).values(values);
+ if (claimSource && typeof statement.onConflictDoNothing === 'function') {
+ statement = statement.onConflictDoNothing({
+ target: [charges.propertyId, charges.folioId, charges.sourceKey],
+ });
+ }
+ const [created] = await statement.returning();
+ if (created || !claimSource) return { row: created, created: true };
+ const [existing] = await tx
.select()
.from(charges)
- .where(
- and(
- eq(charges.id, dto.originalChargeId),
- eq(charges.folioId, folioId),
- eq(charges.propertyId, dto.propertyId),
- ),
+ .where(and(
+ eq(charges.propertyId, propertyId),
+ eq(charges.folioId, folioId),
+ eq(charges.sourceKey, values['sourceKey']),
+ ));
+ return { row: existing, created: false };
+ };
+ const correction = async (
+ base: any,
+ all: any[],
+ key: string,
+ delta: Decimal,
+ desired?: DesiredGroup,
+ groupLocked = false,
+ ) => {
+ if (delta.isZero()) return;
+ const type = key.startsWith('base:') ? key.slice(5) : key === 'tax' ? 'tax' : 'adjustment';
+ const component = all.find((row: any) =>
+ !row.isReversal
+ && componentKey(row, base) === key
+ && !row.adjustsChargeId)
+ ?? all.find((row: any) => !row.isReversal && componentKey(row, base) === key);
+ const affectedServiceDate = desired?.serviceDate ?? calendarDate(base.serviceDate) ?? today;
+ const mustPostOnOpenDate = groupLocked
+ || (latestClosedDate != null && affectedServiceDate <= latestClosedDate);
+ const postingDate = mustPostOnOpenDate ? currentOpenDate : affectedServiceDate;
+ const description = key === 'custom'
+ ? `${component?.description
+ ?? `Accepted price adjustment: ${desired?.customAdjustment?.reason ?? 'stay amendment'}`} correction (affected ${affectedServiceDate})`
+ : `Accepted stay amendment ${key === 'tax' ? 'tax' : type} correction (affected ${affectedServiceDate})`;
+ await insert({
+ propertyId,
+ folioId,
+ type,
+ description: description.slice(0, 255),
+ amount: delta.toFixed(2),
+ currencyCode: newPricing.currencyCode,
+ taxAmount: '0.00',
+ taxRate: component?.taxRate ?? undefined,
+ taxCode: component?.taxCode ?? undefined,
+ serviceDate: new Date(`${postingDate}T00:00:00.000Z`),
+ isReversal: false,
+ adjustsChargeId: component?.id ?? base.id,
+ parentChargeId: base.id,
+ sourceKey: `${amendmentSourcePrefix}${amendmentId}:component:${base.id}:${key}`,
+ postedBy: postedBy ?? undefined,
+ }, true);
+ adjustment = adjustment.plus(delta);
+ };
+
+ for (const base of acceptedBases) {
+ const group = groupRows(base);
+ const desired = desiredGroups.get(base.sourceKey);
+ const actual = componentTotals(base, group.all);
+ const wanted = desiredComponents(desired);
+ const groupLocked = base.isLocked || group.children.some((row: any) => row.isLocked);
+ for (const key of new Set([...actual.keys(), ...wanted.keys()])) {
+ await correction(
+ base,
+ group.all,
+ key,
+ (wanted.get(key) ?? new Decimal(0)).minus(actual.get(key) ?? 0),
+ desired,
+ groupLocked,
);
- if (!original) {
- throw new NotFoundException(`Original charge ${dto.originalChargeId} not found`);
}
- if (dto.isReversal && original.isLocked) {
- throw new BadRequestException('Cannot reverse a locked charge');
+ }
+
+ const postedServiceRows = new Set(
+ acceptedBases
+ .map((base: any) => /^accepted-pricing:reservation-service:([^:]+):/.exec(base.sourceKey)?.[1])
+ .filter(Boolean),
+ );
+ for (const desired of desiredGroups.values()) {
+ if (acceptedBases.some((base: any) => base.sourceKey === desired.sourceKey)) continue;
+ const isClosed = latestClosedDate != null && desired.serviceDate <= latestClosedDate;
+ const isOnce = /:once:\d{4}-\d{2}-\d{2}$/.test(desired.sourceKey);
+ const isDueOnce = isOnce
+ && desired.reservationServiceId != null
+ && postedServiceRows.has(desired.reservationServiceId)
+ && desired.serviceDate <= currentOpenDate;
+ if (!isClosed && !isDueOnce) continue;
+ const postingDate = isClosed ? currentOpenDate : desired.serviceDate;
+ const affectedDateSuffix = postingDate === desired.serviceDate
+ ? ''
+ : ` (affected ${desired.serviceDate})`;
+ const baseOutcome = await insert({
+ propertyId,
+ folioId,
+ type: desired.baseType,
+ description: `${desired.description}${affectedDateSuffix}`.slice(0, 255),
+ amount: new Decimal(desired.baseAmount).toFixed(2),
+ currencyCode: newPricing.currencyCode,
+ taxAmount: '0.00',
+ serviceDate: new Date(`${postingDate}T00:00:00.000Z`),
+ isReversal: false,
+ sourceKey: desired.sourceKey,
+ postedBy: postedBy ?? undefined,
+ }, true);
+ const base = baseOutcome.row;
+ if (!base || !baseOutcome.created) continue;
+ adjustment = adjustment.plus(desired.baseAmount);
+ if (new Decimal(desired.taxAmount).greaterThan(0)) {
+ await insert({
+ propertyId,
+ folioId,
+ type: 'tax',
+ description: `${desired.description} tax`.slice(0, 255),
+ amount: new Decimal(desired.taxAmount).toFixed(2),
+ currencyCode: newPricing.currencyCode,
+ taxAmount: '0.00',
+ serviceDate: new Date(`${postingDate}T00:00:00.000Z`),
+ isReversal: false,
+ parentChargeId: base.id,
+ postedBy: postedBy ?? undefined,
+ });
+ adjustment = adjustment.plus(desired.taxAmount);
}
- // Operational integrity: a reversal cannot itself be reversed. Undo a
- // mistaken reversal by re-posting the original charge.
- if (dto.isReversal && original.isReversal) {
- throw new BadRequestException('Cannot reverse a reversal transaction');
+ if (desired.customAdjustment && !new Decimal(desired.customAdjustment.amount).isZero()) {
+ await insert({
+ propertyId,
+ folioId,
+ type: 'adjustment',
+ description: `Accepted price adjustment: ${desired.customAdjustment.reason}`.slice(0, 255),
+ amount: new Decimal(desired.customAdjustment.amount).toFixed(2),
+ currencyCode: newPricing.currencyCode,
+ taxAmount: '0.00',
+ serviceDate: new Date(`${postingDate}T00:00:00.000Z`),
+ isReversal: false,
+ parentChargeId: base.id,
+ postedBy: postedBy ?? undefined,
+ });
+ adjustment = adjustment.plus(desired.customAdjustment.amount);
}
}
+ await this.recalculateBalance(folioId, propertyId, tx);
+ return { reversedChargeIds, adjustmentAmount: adjustment.toFixed(2) };
+ }
+
+ async postCharge(
+ folioId: string,
+ dto: CreateChargeDto,
+ tx?: any,
+ persistence?: { parentChargeId?: string; sourceKey?: string },
+ ) {
+ const publicInput = dto as unknown as Record;
+ if (['isReversal', 'originalChargeId', 'parentChargeId', 'adjustsChargeId', 'sourceKey']
+ .some((key) => publicInput[key] !== undefined)) {
+ throw new BadRequestException(
+ 'Internal charge provenance cannot be supplied to generic charge posting',
+ );
+ }
+ const db = tx ?? this.db;
+ const folio = await this.findById(folioId, dto.propertyId, tx);
+ if (folio.status !== 'open') {
+ throw new BadRequestException('Cannot post charge to a folio that is not open');
+ }
+
+ // A negative/zero amount inverts or zeroes the folio balance. Generic
+ // posting permits this only for an explicit adjustment; canonical reversal
+ // rows are created solely by reverseCharge().
+ if (
+ new Decimal(dto.amount).lessThanOrEqualTo(0) &&
+ dto.type !== 'adjustment'
+ ) {
+ throw new BadRequestException(
+ 'Charge amount must be positive (negatives are only allowed for adjustments)',
+ );
+ }
- const [charge] = await db
+ const insert = db
.insert(charges)
.values({
propertyId: dto.propertyId,
@@ -343,15 +753,38 @@ export class FolioService {
taxRate: dto.taxRate,
taxCode: dto.taxCode,
serviceDate: new Date(dto.serviceDate),
- isReversal: dto.isReversal ?? false,
- originalChargeId: dto.originalChargeId,
+ isReversal: false,
+ parentChargeId: persistence?.parentChargeId,
+ sourceKey: persistence?.sourceKey,
postedBy: dto.postedBy,
- })
- .returning();
+ });
+ const [charge] = persistence?.sourceKey
+ ? await insert
+ .onConflictDoNothing({
+ target: [charges.propertyId, charges.folioId, charges.sourceKey],
+ })
+ .returning()
+ : await insert.returning();
+ if (!charge && persistence?.sourceKey) {
+ const [existing] = await db
+ .select()
+ .from(charges)
+ .where(and(
+ eq(charges.propertyId, dto.propertyId),
+ eq(charges.folioId, folioId),
+ eq(charges.sourceKey, persistence.sourceKey),
+ ));
+ if (!existing) {
+ throw new ConflictException('Charge source key was claimed without a persisted charge');
+ }
+ const replay = { ...existing, taxCharges: [] };
+ Object.defineProperty(replay, CHARGE_WAS_CREATED, { value: false });
+ return replay;
+ }
// Auto-post tax charges if this is a taxable charge (not a tax or reversal itself)
const taxCharges: any[] = [];
- if (charge.type !== 'tax' && charge.type !== 'adjustment' && !charge.isReversal && !dto.skipTaxCalculation) {
+ if (charge.type !== 'tax' && charge.type !== 'adjustment' && !dto.skipTaxCalculation) {
const taxItems = await this.taxService.calculateTaxes(
dto.amount,
dto.type,
@@ -384,118 +817,293 @@ export class FolioService {
await this.recalculateBalance(folioId, dto.propertyId, tx);
- await this.webhookService.emit(
- 'folio.charge_posted',
- 'charge',
- charge.id,
- { folioId, type: charge.type, amount: charge.amount, description: charge.description },
- dto.propertyId,
- );
+ if (!tx) {
+ await this.webhookService.emit(
+ 'folio.charge_posted',
+ 'charge',
+ charge.id,
+ { folioId, type: charge.type, amount: charge.amount, description: charge.description },
+ dto.propertyId,
+ );
+ }
- return { ...charge, taxCharges };
+ const result = { ...charge, taxCharges };
+ Object.defineProperty(result, CHARGE_WAS_CREATED, { value: true });
+ return result;
}
- async reverseCharge(folioId: string, chargeId: string, propertyId: string) {
- const [original] = await this.db
- .select()
- .from(charges)
- .where(
- and(
- eq(charges.id, chargeId),
- eq(charges.folioId, folioId),
- eq(charges.propertyId, propertyId),
- ),
- );
- if (!original) {
- throw new NotFoundException(`Charge ${chargeId} not found`);
- }
- if (original.isLocked) {
- throw new BadRequestException('Cannot reverse a locked charge');
- }
- // Operational integrity: a reversal cannot itself be reversed. Undo a
- // mistaken reversal by re-posting the original charge.
- if (original.isReversal) {
- throw new BadRequestException('Cannot reverse a reversal transaction');
- }
+ /** Post an immutable accepted base/tax pair atomically without live tax lookup. */
+ async postChargeFromSnapshot(
+ folioId: string,
+ dto: CreateChargeDto,
+ taxAmount: string,
+ adjustment?: { amount: string; reason: string },
+ sourceKey?: string,
+ ) {
+ const outcome = await this.postChargeFromSnapshotWithOutcome(
+ folioId,
+ dto,
+ taxAmount,
+ adjustment,
+ sourceKey,
+ );
+ return outcome.charge;
+ }
- // Check if already reversed
- const [existing] = await this.db
- .select()
- .from(charges)
- .where(
- and(
- eq(charges.originalChargeId, chargeId),
- eq(charges.isReversal, true),
- ),
- );
- if (existing) {
- throw new BadRequestException('Charge has already been reversed');
+ /**
+ * Internal domain-service seam for source-key consumers. HTTP-facing charge
+ * shapes continue to use postChargeFromSnapshot and never expose wasCreated.
+ */
+ async postChargeFromSnapshotWithOutcome(
+ folioId: string,
+ dto: CreateChargeDto,
+ taxAmount: string,
+ adjustment?: { amount: string; reason: string },
+ sourceKey?: string,
+ existingTx?: any,
+ ) {
+ const postInTransaction = async (tx: any) => {
+ const base = await this.postCharge(folioId, {
+ ...dto,
+ skipTaxCalculation: true,
+ }, tx, { sourceKey });
+ if ((base as any)[CHARGE_WAS_CREATED] === false) {
+ const children = await tx
+ .select()
+ .from(charges)
+ .where(and(
+ eq(charges.propertyId, dto.propertyId),
+ eq(charges.folioId, folioId),
+ eq(charges.parentChargeId, base.id),
+ eq(charges.isReversal, false),
+ ));
+ return {
+ ...base,
+ taxCharges: children.filter((child: any) => child.type === 'tax'),
+ adjustmentCharges: children.filter((child: any) => child.type === 'adjustment'),
+ wasCreated: false,
+ };
+ }
+ const taxCharges: any[] = [];
+ if (new Decimal(taxAmount).greaterThan(0)) {
+ const frozenTax = await this.postCharge(folioId, {
+ propertyId: dto.propertyId,
+ type: 'tax',
+ description: `${dto.description} tax`.slice(0, 255),
+ amount: new Decimal(taxAmount).toFixed(2),
+ currencyCode: dto.currencyCode,
+ serviceDate: dto.serviceDate,
+ postedBy: dto.postedBy,
+ skipTaxCalculation: true,
+ }, tx, { parentChargeId: base.id });
+ const { taxCharges: _nestedTaxes, ...taxCharge } = frozenTax;
+ void _nestedTaxes;
+ taxCharges.push(taxCharge);
+ }
+ const adjustmentCharges: any[] = [];
+ if (adjustment && !new Decimal(adjustment.amount).isZero()) {
+ const frozenAdjustment = await this.postCharge(folioId, {
+ propertyId: dto.propertyId,
+ type: 'adjustment',
+ description: `Accepted price adjustment: ${adjustment.reason}`.slice(0, 255),
+ amount: new Decimal(adjustment.amount).toFixed(2),
+ currencyCode: dto.currencyCode,
+ serviceDate: dto.serviceDate,
+ postedBy: dto.postedBy,
+ skipTaxCalculation: true,
+ }, tx, { parentChargeId: base.id });
+ const { taxCharges: _nestedTaxes, ...adjustmentCharge } = frozenAdjustment;
+ void _nestedTaxes;
+ adjustmentCharges.push(adjustmentCharge);
+ }
+ return { ...base, taxCharges, adjustmentCharges, wasCreated: true };
+ };
+ const result = existingTx
+ ? await postInTransaction(existingTx)
+ : await this.db.transaction(postInTransaction);
+
+ if (!result.wasCreated) {
+ const { wasCreated: _wasCreated, ...existing } = result;
+ void _wasCreated;
+ return { charge: existing, wasCreated: false as const };
}
- const negatedAmount = new Decimal(original.amount).negated().toFixed(2);
- const negatedTax = new Decimal(original.taxAmount).negated().toFixed(2);
+ const { wasCreated: _wasCreated, ...posted } = result;
+ void _wasCreated;
+ const outcome = { charge: posted, wasCreated: true as const };
+ if (!existingTx) {
+ await this.emitSnapshotChargeWebhooks(folioId, dto.propertyId, outcome);
+ }
+ return outcome;
+ }
- const [reversal] = await this.db
- .insert(charges)
- .values({
+ /** Dispatch immutable charge-group events after the caller's transaction commits. */
+ async emitSnapshotChargeWebhooks(
+ folioId: string,
+ propertyId: string,
+ outcome: { charge: any; wasCreated: boolean },
+ ): Promise {
+ if (!outcome.wasCreated) return;
+ const posted = outcome.charge;
+ for (const charge of [
+ posted,
+ ...(posted.taxCharges ?? []),
+ ...(posted.adjustmentCharges ?? []),
+ ]) {
+ await this.webhookService.emit(
+ 'folio.charge_posted',
+ 'charge',
+ charge.id,
+ {
+ folioId,
+ type: charge.type,
+ amount: charge.amount,
+ description: charge.description,
+ },
propertyId,
- folioId,
- type: original.type,
- description: `Reversal: ${original.description}`,
- amount: negatedAmount,
- currencyCode: original.currencyCode,
- taxAmount: negatedTax,
- taxRate: original.taxRate,
- taxCode: original.taxCode,
- serviceDate: original.serviceDate,
- isReversal: true,
- originalChargeId: chargeId,
- })
- .returning();
-
- // Cascade: reverse all child tax charges linked to this charge
- const childTaxCharges = await this.db
- .select()
- .from(charges)
- .where(
- and(
- eq(charges.parentChargeId, chargeId),
- eq(charges.type, 'tax' as any),
- eq(charges.isReversal, false),
- ),
);
+ }
+ }
- for (const taxCharge of childTaxCharges) {
- // Check not already reversed
- const [existingTaxReversal] = await this.db
+ async reverseCharge(folioId: string, chargeId: string, propertyId: string) {
+ const reverseInTransaction = async (db: any) => {
+ const originalQuery = db
.select()
.from(charges)
.where(
- and(eq(charges.originalChargeId, taxCharge.id), eq(charges.isReversal, true)),
+ and(
+ eq(charges.id, chargeId),
+ eq(charges.folioId, folioId),
+ eq(charges.propertyId, propertyId),
+ ),
+ );
+ const [original] = typeof originalQuery.for === 'function'
+ ? await originalQuery.for('update')
+ : await originalQuery;
+ if (!original) {
+ throw new NotFoundException(`Charge ${chargeId} not found`);
+ }
+ if (original.isLocked) {
+ throw new BadRequestException('Cannot reverse a locked charge');
+ }
+ // Operational integrity: a reversal cannot itself be reversed. Undo a
+ // mistaken reversal by re-posting the original charge.
+ if (original.isReversal) {
+ throw new BadRequestException('Cannot reverse a reversal transaction');
+ }
+ if (original.adjustsChargeId) {
+ throw new BadRequestException(
+ 'Cannot reverse an internal accepted-pricing correction',
+ );
+ }
+ if (original.parentChargeId) {
+ const [parent] = await db
+ .select()
+ .from(charges)
+ .where(and(
+ eq(charges.id, original.parentChargeId),
+ eq(charges.folioId, folioId),
+ eq(charges.propertyId, propertyId),
+ ));
+ if (typeof parent?.sourceKey === 'string'
+ && parent.sourceKey.startsWith('accepted-pricing:')) {
+ throw new BadRequestException(
+ 'Reverse the accepted-pricing group from its base charge',
+ );
+ }
+ }
+
+ // The original row lock serializes competing whole-group reversals.
+ const [existing] = await db
+ .select()
+ .from(charges)
+ .where(
+ and(
+ eq(charges.originalChargeId, chargeId),
+ eq(charges.isReversal, true),
+ eq(charges.propertyId, propertyId),
+ ),
);
- if (existingTaxReversal) continue;
+ if (existing) {
+ throw new BadRequestException('Charge has already been reversed');
+ }
- await this.db
+ const [reversal] = await db
.insert(charges)
.values({
propertyId,
folioId,
- type: 'tax',
- description: `Reversal: ${taxCharge.description}`,
- amount: new Decimal(taxCharge.amount).negated().toFixed(2),
- currencyCode: taxCharge.currencyCode,
- taxAmount: '0',
- taxRate: taxCharge.taxRate,
- taxCode: taxCharge.taxCode,
- serviceDate: taxCharge.serviceDate,
+ type: original.type,
+ description: `Reversal: ${original.description}`,
+ amount: new Decimal(original.amount).negated().toFixed(2),
+ currencyCode: original.currencyCode,
+ taxAmount: new Decimal(original.taxAmount).negated().toFixed(2),
+ taxRate: original.taxRate,
+ taxCode: original.taxCode,
+ serviceDate: original.serviceDate,
isReversal: true,
- originalChargeId: taxCharge.id,
- parentChargeId: reversal.id,
+ originalChargeId: chargeId,
})
.returning();
- }
- await this.recalculateBalance(folioId, propertyId);
+ // Cascade every immutable component linked to the base. Canonical
+ // live-tax rows and frozen tax/custom-adjustment rows all share
+ // parentChargeId. Locking children also makes a concurrent direct child
+ // reversal resolve before this group decides whether it still needs one.
+ const childQuery = db
+ .select()
+ .from(charges)
+ .where(
+ and(
+ eq(charges.parentChargeId, chargeId),
+ eq(charges.isReversal, false),
+ eq(charges.propertyId, propertyId),
+ ),
+ );
+ const childCharges = typeof childQuery.for === 'function'
+ ? await childQuery.for('update')
+ : await childQuery;
+
+ for (const childCharge of childCharges) {
+ const [existingChildReversal] = await db
+ .select()
+ .from(charges)
+ .where(
+ and(
+ eq(charges.originalChargeId, childCharge.id),
+ eq(charges.isReversal, true),
+ eq(charges.propertyId, propertyId),
+ ),
+ );
+ if (existingChildReversal) continue;
+
+ await db
+ .insert(charges)
+ .values({
+ propertyId,
+ folioId,
+ type: childCharge.type,
+ description: `Reversal: ${childCharge.description}`,
+ amount: new Decimal(childCharge.amount).negated().toFixed(2),
+ currencyCode: childCharge.currencyCode,
+ taxAmount: new Decimal(childCharge.taxAmount ?? '0').negated().toFixed(2),
+ taxRate: childCharge.taxRate,
+ taxCode: childCharge.taxCode,
+ serviceDate: childCharge.serviceDate,
+ isReversal: true,
+ originalChargeId: childCharge.id,
+ parentChargeId: reversal.id,
+ })
+ .returning();
+ }
+
+ await this.recalculateBalance(folioId, propertyId, db);
+ return reversal;
+ };
+
+ const reversal = typeof this.db.transaction === 'function'
+ ? await this.db.transaction(reverseInTransaction)
+ : await reverseInTransaction(this.db);
await this.webhookService.emit(
'folio.charge_posted',
@@ -537,8 +1145,67 @@ export class FolioService {
.where(whereClause),
]);
+ const pageIds: string[] = data.map((charge: any) => charge.id);
+ const parentIds: string[] = [...new Set(
+ data
+ .map((charge: any) => charge.parentChargeId)
+ .filter((id: unknown): id is string => typeof id === 'string'),
+ )];
+ const metadataPredicates: any[] = [];
+ if (parentIds.length > 0) {
+ metadataPredicates.push(and(
+ eq(charges.folioId, folioId),
+ inArray(charges.id, parentIds),
+ ));
+ }
+ if (pageIds.length > 0) {
+ metadataPredicates.push(and(
+ eq(charges.isReversal, true),
+ inArray(charges.originalChargeId, pageIds),
+ ));
+ }
+ const relatedCharges = metadataPredicates.length > 0
+ ? await this.db
+ .select()
+ .from(charges)
+ .where(and(
+ eq(charges.propertyId, dto.propertyId),
+ or(...metadataPredicates),
+ ))
+ : [];
+ const acceptedParentIds = new Set(
+ relatedCharges
+ .filter((charge: any) => parentIds.includes(charge.id)
+ && typeof charge.sourceKey === 'string'
+ && charge.sourceKey.startsWith('accepted-pricing:'))
+ .map((charge: any) => charge.id),
+ );
+ const reversedOriginalIds = new Set(
+ relatedCharges
+ .filter((charge: any) => charge.isReversal && charge.originalChargeId)
+ .map((charge: any) => charge.originalChargeId),
+ );
+
return {
- data,
+ // The authority hints include related rows outside this page. That keeps
+ // accepted-pricing children internal and already-reversed originals
+ // non-reversible without treating ordinary tax children as internal.
+ data: data.map((charge: any) => {
+ const isAcceptedChild = Boolean(
+ charge.parentChargeId && acceptedParentIds.has(charge.parentChargeId),
+ );
+ const isIndividuallyOperable = !charge.isLocked
+ && !charge.isReversal
+ && !charge.adjustsChargeId
+ && !isAcceptedChild;
+ return {
+ ...charge,
+ canReverse: isIndividuallyOperable && !reversedOriginalIds.has(charge.id),
+ canMove: isIndividuallyOperable
+ && !(typeof charge.sourceKey === 'string'
+ && charge.sourceKey.startsWith('accepted-pricing:')),
+ };
+ }),
total: Number(countResult[0]?.count ?? 0),
page,
limit,
@@ -584,7 +1251,7 @@ export class FolioService {
bookingId?: string | null;
guestId: string;
currencyCode: string;
- }) {
+ }, tx?: any) {
return this.create({
propertyId: reservation.propertyId,
reservationId: reservation.id,
@@ -592,7 +1259,7 @@ export class FolioService {
guestId: reservation.guestId,
type: 'guest',
currencyCode: reservation.currencyCode,
- });
+ }, tx);
}
private async generateFolioNumber(propertyId: string, tx?: any): Promise {
diff --git a/apps/api/src/modules/guest/guest.service.ts b/apps/api/src/modules/guest/guest.service.ts
index 45e477d3..704a46e6 100644
--- a/apps/api/src/modules/guest/guest.service.ts
+++ b/apps/api/src/modules/guest/guest.service.ts
@@ -60,12 +60,13 @@ export class GuestService {
}
}
- async create(dto: CreateGuestDto) {
+ async create(dto: CreateGuestDto, tx?: any) {
+ const db = tx ?? this.db;
const values: Record = { ...dto };
if (dto.gdprConsentMarketing) {
values['gdprConsentDate'] = new Date();
}
- const [guest] = await this.db.insert(guests).values(values).returning();
+ const [guest] = await db.insert(guests).values(values).returning();
return guest;
}
diff --git a/apps/api/src/modules/migration/migration-source-credentials.service.spec.ts b/apps/api/src/modules/migration/migration-source-credentials.service.spec.ts
index 904ce6f9..21a69a6d 100644
--- a/apps/api/src/modules/migration/migration-source-credentials.service.spec.ts
+++ b/apps/api/src/modules/migration/migration-source-credentials.service.spec.ts
@@ -12,6 +12,14 @@ vi.mock('drizzle-orm', () => ({
}));
vi.mock('@telivityhaip/database', () => ({
+ // `database.module.ts` (imported transitively via `../auth/api-key.guard`
+ // → `DRIZZLE`) now re-exports these two from `@telivityhaip/database`
+ // itself (a single canonical `DRIZZLE` symbol shared across the optional
+ // `@telivityhaip/booking-requests` package boundary) instead of defining
+ // its own local symbol — this narrow mock must supply both so that static
+ // import doesn't throw, even though this test never uses either value.
+ DRIZZLE: Symbol('DRIZZLE-test-mock'),
+ postgresOptionsFromEnv: vi.fn(() => ({})),
auditLogs: {
__table: 'auditLogs',
propertyId: 'audit.propertyId',
diff --git a/apps/api/src/modules/night-audit/night-audit.service.spec.ts b/apps/api/src/modules/night-audit/night-audit.service.spec.ts
index f3e672e6..5c6381d1 100644
--- a/apps/api/src/modules/night-audit/night-audit.service.spec.ts
+++ b/apps/api/src/modules/night-audit/night-audit.service.spec.ts
@@ -13,6 +13,20 @@ import { PolicyService } from '../policy/policy.service';
import { DepositSettlementService } from '../accounting/deposit-settlement.service';
const mockFolioService = {
+ emitSnapshotChargeWebhooks: vi.fn().mockResolvedValue(undefined),
+ postChargeFromSnapshotWithOutcome: vi.fn().mockResolvedValue({
+ charge: {
+ id: 'charge-room-snapshot',
+ amount: '123.00',
+ taxCharges: [{ id: 'tax-snapshot', amount: '12.00' }],
+ },
+ wasCreated: true,
+ }),
+ postChargeFromSnapshot: vi.fn().mockResolvedValue({
+ id: 'charge-room-snapshot',
+ amount: '123.00',
+ taxCharges: [{ id: 'tax-snapshot', amount: '12.00' }],
+ }),
postCharge: vi.fn().mockResolvedValue({
id: 'charge-room-001',
amount: '150.00',
@@ -127,10 +141,11 @@ function createMockDb(overrides: {
let selectCallCount = 0;
- return {
+ const db: any = {
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
+ for: vi.fn().mockResolvedValue([{ id: 'res-001' }]),
then: (resolve: any) => {
const result = selectResults[selectCallCount] ?? selectResults[selectResults.length - 1]!;
selectCallCount++;
@@ -201,6 +216,9 @@ function createMockDb(overrides: {
}),
}),
};
+ db.execute = vi.fn(async () => undefined);
+ db.transaction = vi.fn(async (work: (tx: any) => Promise) => work(db));
+ return db;
}
describe('NightAuditService', () => {
@@ -338,6 +356,289 @@ describe('NightAuditService', () => {
}));
});
+ it('posts the accepted nightly room and tax snapshot without live repricing', async () => {
+ const acceptedReservation = {
+ ...mockReservation,
+ acceptedPricingSnapshot: {
+ version: 1,
+ source: 'current',
+ currencyCode: 'USD',
+ grandTotal: '405.00',
+ roomTotal: '369.00',
+ taxTotal: '36.00',
+ nights: [
+ { date: '2026-04-04', roomAmount: '123.00', taxAmount: '12.00' },
+ { date: '2026-04-05', roomAmount: '123.00', taxAmount: '12.00' },
+ { date: '2026-04-06', roomAmount: '123.00', taxAmount: '12.00' },
+ ],
+ services: [],
+ servicesTotal: '0.00',
+ servicesTaxTotal: '0.00',
+ adjustment: null,
+ },
+ };
+ const db = createMockDb({
+ selectResults: [
+ [acceptedReservation],
+ [acceptedReservation],
+ [mockFolio],
+ ],
+ });
+ const module = await Test.createTestingModule({
+ providers: [
+ NightAuditService,
+ { provide: DRIZZLE, useValue: db },
+ { provide: FolioService, useValue: mockFolioService },
+ { provide: ReservationService, useValue: mockReservationService },
+ { provide: HousekeepingService, useValue: mockHousekeepingService },
+ { provide: RoomStatusService, useValue: mockRoomStatusService },
+ { provide: WebhookService, useValue: mockWebhookService },
+ { provide: AncillaryService, useValue: mockAncillaryService },
+ { provide: PolicyService, useValue: mockPolicyService },
+ { provide: DepositSettlementService, useValue: mockDepositSettlementService },
+ ],
+ }).compile();
+ service = module.get(NightAuditService);
+
+ const result = await service.postRoomTariffs('prop-001', '2026-04-06');
+
+ expect(mockFolioService.postChargeFromSnapshotWithOutcome).toHaveBeenCalledWith(
+ 'folio-001',
+ expect.objectContaining({ type: 'room', amount: '123.00' }),
+ '12.00',
+ undefined,
+ 'accepted-pricing:reservation:res-001:night:2026-04-06',
+ expect.anything(),
+ );
+ expect(mockFolioService.postCharge).not.toHaveBeenCalled();
+ expect(result).toMatchObject({ totalRoom: '123.00', totalTax: '12.00', count: 1 });
+ });
+
+ it('re-reads the accepted room snapshot under the pricing lock before claiming a night', async () => {
+ const staleReservation = {
+ ...mockReservation,
+ acceptedPricingSnapshot: {
+ version: 1,
+ source: 'current',
+ currencyCode: 'USD',
+ grandTotal: '135.00',
+ roomTotal: '123.00',
+ taxTotal: '12.00',
+ nights: [{ date: '2026-04-06', roomAmount: '123.00', taxAmount: '12.00' }],
+ services: [],
+ servicesTotal: '0.00',
+ servicesTaxTotal: '0.00',
+ adjustment: null,
+ },
+ };
+ const lockedReservation = {
+ ...staleReservation,
+ acceptedPricingSnapshot: {
+ ...staleReservation.acceptedPricingSnapshot,
+ grandTotal: '0.00',
+ roomTotal: '0.00',
+ taxTotal: '0.00',
+ nights: [],
+ },
+ };
+ const db = createMockDb({
+ selectResults: [[staleReservation], [lockedReservation]],
+ });
+ const module = await Test.createTestingModule({
+ providers: [
+ NightAuditService,
+ { provide: DRIZZLE, useValue: db },
+ { provide: FolioService, useValue: mockFolioService },
+ { provide: ReservationService, useValue: mockReservationService },
+ { provide: HousekeepingService, useValue: mockHousekeepingService },
+ { provide: RoomStatusService, useValue: mockRoomStatusService },
+ { provide: WebhookService, useValue: mockWebhookService },
+ { provide: AncillaryService, useValue: mockAncillaryService },
+ { provide: PolicyService, useValue: mockPolicyService },
+ { provide: DepositSettlementService, useValue: mockDepositSettlementService },
+ ],
+ }).compile();
+ service = module.get(NightAuditService);
+
+ const result = await service.postRoomTariffs('prop-001', '2026-04-06');
+
+ expect(result.count).toBe(0);
+ expect(mockFolioService.postChargeFromSnapshotWithOutcome).not.toHaveBeenCalled();
+ });
+
+ it('posts a custom accepted-price delta once with the arrival-night snapshot', async () => {
+ const acceptedReservation = {
+ ...mockReservation,
+ acceptedPricingSnapshot: {
+ version: 1,
+ source: 'custom',
+ currencyCode: 'USD',
+ grandTotal: '390.00',
+ roomTotal: '369.00',
+ taxTotal: '36.00',
+ nights: [
+ { date: '2026-04-04', roomAmount: '123.00', taxAmount: '12.00' },
+ { date: '2026-04-05', roomAmount: '123.00', taxAmount: '12.00' },
+ { date: '2026-04-06', roomAmount: '123.00', taxAmount: '12.00' },
+ ],
+ services: [],
+ servicesTotal: '0.00',
+ servicesTaxTotal: '0.00',
+ adjustment: {
+ amount: '-15.00',
+ reason: 'Staff loyalty adjustment',
+ serviceDate: '2026-04-04',
+ },
+ },
+ };
+ const db = createMockDb({
+ selectResults: [[acceptedReservation], [acceptedReservation], [mockFolio]],
+ });
+ const module = await Test.createTestingModule({
+ providers: [
+ NightAuditService,
+ { provide: DRIZZLE, useValue: db },
+ { provide: FolioService, useValue: mockFolioService },
+ { provide: ReservationService, useValue: mockReservationService },
+ { provide: HousekeepingService, useValue: mockHousekeepingService },
+ { provide: RoomStatusService, useValue: mockRoomStatusService },
+ { provide: WebhookService, useValue: mockWebhookService },
+ { provide: AncillaryService, useValue: mockAncillaryService },
+ { provide: PolicyService, useValue: mockPolicyService },
+ { provide: DepositSettlementService, useValue: mockDepositSettlementService },
+ ],
+ }).compile();
+ service = module.get(NightAuditService);
+
+ await service.postRoomTariffs('prop-001', '2026-04-04');
+
+ expect(mockFolioService.postChargeFromSnapshotWithOutcome).toHaveBeenCalledWith(
+ 'folio-001',
+ expect.objectContaining({ type: 'room', amount: '123.00' }),
+ '12.00',
+ {
+ amount: '-15.00',
+ reason: 'Staff loyalty adjustment',
+ },
+ 'accepted-pricing:reservation:res-001:night:2026-04-04',
+ expect.anything(),
+ );
+ });
+
+ it('does not let an unrelated manual room charge suppress the canonical accepted group', async () => {
+ const acceptedReservation = {
+ ...mockReservation,
+ acceptedPricingSnapshot: {
+ version: 1,
+ source: 'current',
+ currencyCode: 'USD',
+ grandTotal: '135.00',
+ roomTotal: '123.00',
+ taxTotal: '12.00',
+ nights: [{ date: '2026-04-06', roomAmount: '123.00', taxAmount: '12.00' }],
+ services: [],
+ servicesTotal: '0.00',
+ servicesTaxTotal: '0.00',
+ adjustment: null,
+ },
+ };
+ const db = createMockDb({
+ selectResults: [
+ [acceptedReservation],
+ [acceptedReservation],
+ [mockFolio],
+ ],
+ });
+ const module = await Test.createTestingModule({
+ providers: [
+ NightAuditService,
+ { provide: DRIZZLE, useValue: db },
+ { provide: FolioService, useValue: mockFolioService },
+ { provide: ReservationService, useValue: mockReservationService },
+ { provide: HousekeepingService, useValue: mockHousekeepingService },
+ { provide: RoomStatusService, useValue: mockRoomStatusService },
+ { provide: WebhookService, useValue: mockWebhookService },
+ { provide: AncillaryService, useValue: mockAncillaryService },
+ { provide: PolicyService, useValue: mockPolicyService },
+ { provide: DepositSettlementService, useValue: mockDepositSettlementService },
+ ],
+ }).compile();
+ service = module.get(NightAuditService);
+
+ const result = await service.postRoomTariffs('prop-001', '2026-04-06');
+
+ expect(mockFolioService.postChargeFromSnapshotWithOutcome).toHaveBeenCalledWith(
+ 'folio-001',
+ expect.objectContaining({ amount: '123.00' }),
+ '12.00',
+ undefined,
+ 'accepted-pricing:reservation:res-001:night:2026-04-06',
+ expect.anything(),
+ );
+ expect(result.count).toBe(1);
+ });
+
+ it('counts an accepted room group only for the canonical source-key winner', async () => {
+ const acceptedReservation = {
+ ...mockReservation,
+ acceptedPricingSnapshot: {
+ version: 1,
+ source: 'current',
+ currencyCode: 'USD',
+ grandTotal: '135.00',
+ roomTotal: '123.00',
+ taxTotal: '12.00',
+ nights: [{ date: '2026-04-06', roomAmount: '123.00', taxAmount: '12.00' }],
+ services: [],
+ servicesTotal: '0.00',
+ servicesTaxTotal: '0.00',
+ adjustment: null,
+ },
+ };
+ const db = createMockDb({ selectResults: [
+ [acceptedReservation], [acceptedReservation], [mockFolio],
+ [acceptedReservation], [acceptedReservation], [mockFolio],
+ ] });
+ mockFolioService.postChargeFromSnapshotWithOutcome
+ .mockResolvedValueOnce({
+ charge: {
+ id: 'canonical-room',
+ amount: '123.00',
+ taxCharges: [{ id: 'canonical-tax', amount: '12.00' }],
+ },
+ wasCreated: true,
+ })
+ .mockResolvedValueOnce({
+ charge: {
+ id: 'canonical-room',
+ amount: '123.00',
+ taxCharges: [{ id: 'canonical-tax', amount: '12.00' }],
+ },
+ wasCreated: false,
+ });
+ const module = await Test.createTestingModule({
+ providers: [
+ NightAuditService,
+ { provide: DRIZZLE, useValue: db },
+ { provide: FolioService, useValue: mockFolioService },
+ { provide: ReservationService, useValue: mockReservationService },
+ { provide: HousekeepingService, useValue: mockHousekeepingService },
+ { provide: RoomStatusService, useValue: mockRoomStatusService },
+ { provide: WebhookService, useValue: mockWebhookService },
+ { provide: AncillaryService, useValue: mockAncillaryService },
+ { provide: PolicyService, useValue: mockPolicyService },
+ { provide: DepositSettlementService, useValue: mockDepositSettlementService },
+ ],
+ }).compile();
+ service = module.get(NightAuditService);
+
+ const first = await service.postRoomTariffs('prop-001', '2026-04-06');
+ const replay = await service.postRoomTariffs('prop-001', '2026-04-06');
+
+ expect(first).toMatchObject({ count: 1, totalRoom: '123.00', totalTax: '12.00' });
+ expect(replay).toMatchObject({ count: 0, totalRoom: '0.00', totalTax: '0.00' });
+ });
+
it('should skip tariff if already posted for date (idempotent)', async () => {
const db = createMockDb({
selectResults: [
diff --git a/apps/api/src/modules/night-audit/night-audit.service.ts b/apps/api/src/modules/night-audit/night-audit.service.ts
index 8a38d04a..3ba316f4 100644
--- a/apps/api/src/modules/night-audit/night-audit.service.ts
+++ b/apps/api/src/modules/night-audit/night-audit.service.ts
@@ -5,7 +5,7 @@ import {
BadRequestException,
ConflictException,
} from '@nestjs/common';
-import { eq, and, sql, lte } from 'drizzle-orm';
+import { eq, and, inArray, sql, lte } from 'drizzle-orm';
import Decimal from 'decimal.js';
import {
auditRuns,
@@ -17,6 +17,7 @@ import {
rooms,
} from '@telivityhaip/database';
import { DRIZZLE } from '../../database/database.module';
+import { withAcceptedPricingLock } from '../../common/database/accepted-pricing-lock';
import { FolioService } from '../folio/folio.service';
import { ReservationService } from '../reservation/reservation.service';
import { HousekeepingService } from '../housekeeping/housekeeping.service';
@@ -164,6 +165,94 @@ export class NightAuditService {
for (const reservation of inHouseReservations) {
try {
+ if (reservation.acceptedPricingSnapshot) {
+ const lockedPost = await withAcceptedPricingLock(
+ this.db,
+ propertyId,
+ reservation.id,
+ async (tx) => {
+ const [currentReservation] = await tx
+ .select()
+ .from(reservations)
+ .where(and(
+ eq(reservations.id, reservation.id),
+ eq(reservations.propertyId, propertyId),
+ inArray(reservations.status, ['checked_in', 'stayover', 'due_out']),
+ ));
+ const acceptedPricing = currentReservation?.acceptedPricingSnapshot;
+ const acceptedNight = acceptedPricing?.nights?.find(
+ (night: { date: string }) => night.date === businessDate,
+ );
+ if (!currentReservation || !acceptedPricing || !acceptedNight) return null;
+
+ const [folio] = await tx
+ .select()
+ .from(folios)
+ .where(and(
+ eq(folios.reservationId, currentReservation.id),
+ eq(folios.propertyId, propertyId),
+ eq(folios.type, 'guest' as any),
+ eq(folios.status, 'open' as any),
+ ));
+ if (!folio) {
+ return { missingFolio: true as const, reservation: currentReservation };
+ }
+
+ const acceptedAdjustment = acceptedPricing.adjustment?.serviceDate === businessDate
+ ? {
+ amount: acceptedPricing.adjustment.amount,
+ reason: acceptedPricing.adjustment.reason,
+ }
+ : undefined;
+ const outcome = await this.folioService.postChargeFromSnapshotWithOutcome(
+ folio.id,
+ {
+ propertyId,
+ type: 'room',
+ description: `Room tariff - ${businessDate}`,
+ amount: acceptedNight.roomAmount,
+ currencyCode: acceptedPricing.currencyCode,
+ serviceDate: new Date(`${businessDate}T00:00:00Z`).toISOString(),
+ guestId: currentReservation.guestId,
+ },
+ acceptedNight.taxAmount,
+ acceptedAdjustment,
+ `accepted-pricing:reservation:${currentReservation.id}:night:${businessDate}`,
+ tx,
+ );
+ return {
+ missingFolio: false as const,
+ folio,
+ rate: acceptedNight.roomAmount,
+ outcome,
+ };
+ },
+ );
+ if (!lockedPost) continue;
+ if (lockedPost.missingFolio) {
+ errors.push({
+ message: `No open folio for reservation ${reservation.id}`,
+ entity: reservation.id,
+ });
+ continue;
+ }
+ await this.folioService.emitSnapshotChargeWebhooks(
+ lockedPost.folio.id,
+ propertyId,
+ lockedPost.outcome,
+ );
+ if (!lockedPost.outcome.wasCreated) continue;
+ const acceptedTax = (lockedPost.outcome.charge.taxCharges ?? [])
+ .reduce(
+ (sum: Decimal, tax: any) => sum.plus(new Decimal(tax.amount)),
+ new Decimal(0),
+ );
+ totalRoom = totalRoom.plus(new Decimal(lockedPost.rate));
+ totalTax = totalTax.plus(acceptedTax);
+ count++;
+ continue;
+ }
+
// Find open guest folio
const [folio] = await this.db
.select()
@@ -185,8 +274,9 @@ export class NightAuditService {
continue;
}
- // Idempotency: check if room charge already posted for this date
const serviceDateStart = new Date(businessDate + 'T00:00:00Z');
+ // Legacy live-rate postings have no stable source key, so retain the
+ // historical date/type preflight only for that path.
const [existingCharge] = await this.db
.select({ id: charges.id })
.from(charges)
@@ -199,26 +289,15 @@ export class NightAuditService {
sql`${charges.serviceDate}::date = ${businessDate}`,
),
);
+ if (existingCharge) continue;
- if (existingCharge) {
- continue; // Already posted, skip
- }
-
- // Get nightly rate from rate plan or fallback
- let rate: string;
const [ratePlan] = await this.db
.select({ baseAmount: ratePlans.baseAmount })
.from(ratePlans)
.where(eq(ratePlans.id, reservation.ratePlanId));
-
- if (ratePlan) {
- rate = ratePlan.baseAmount;
- } else {
- // Fallback: total / nights
- rate = new Decimal(reservation.totalAmount).div(reservation.nights).toFixed(2);
- }
-
- // Post room tariff — TaxService auto-posts tax charges via FolioService
+ const rate = ratePlan
+ ? ratePlan.baseAmount
+ : new Decimal(reservation.totalAmount).div(reservation.nights).toFixed(2);
const result = await this.folioService.postCharge(folio.id, {
propertyId,
type: 'room',
diff --git a/apps/api/src/modules/payment/booking-request-stripe-handler.interface.spec.ts b/apps/api/src/modules/payment/booking-request-stripe-handler.interface.spec.ts
new file mode 100644
index 00000000..1e7bd4e2
--- /dev/null
+++ b/apps/api/src/modules/payment/booking-request-stripe-handler.interface.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import { isBookingRequestsEnabled } from '@telivityhaip/shared';
+import { paymentHasBookingRequestId } from './booking-request-stripe-handler.interface';
+
+describe('booking-request stripe handler helpers', () => {
+ it('detects booking request payments', () => {
+ expect(paymentHasBookingRequestId({ bookingRequestId: 'br-1' } as any)).toBe(true);
+ expect(paymentHasBookingRequestId({ bookingRequestId: null } as any)).toBe(false);
+ expect(paymentHasBookingRequestId({} as any)).toBe(false);
+ });
+
+ it('reads HAIP_BOOKING_REQUESTS flag', () => {
+ const previous = process.env['HAIP_BOOKING_REQUESTS'];
+ process.env['HAIP_BOOKING_REQUESTS'] = 'true';
+ expect(isBookingRequestsEnabled()).toBe(true);
+ process.env['HAIP_BOOKING_REQUESTS'] = 'false';
+ expect(isBookingRequestsEnabled()).toBe(false);
+ if (previous === undefined) delete process.env['HAIP_BOOKING_REQUESTS'];
+ else process.env['HAIP_BOOKING_REQUESTS'] = previous;
+ });
+});
diff --git a/apps/api/src/modules/payment/booking-request-stripe-handler.interface.ts b/apps/api/src/modules/payment/booking-request-stripe-handler.interface.ts
new file mode 100644
index 00000000..4307acb2
--- /dev/null
+++ b/apps/api/src/modules/payment/booking-request-stripe-handler.interface.ts
@@ -0,0 +1,7 @@
+/** Canonical definition lives in @telivityhaip/shared (used by @telivityhaip/booking-requests too). */
+export {
+ type BookingRequestStripeHandler,
+ type BookingRequestStripePaymentRow,
+ BOOKING_REQUEST_STRIPE_HANDLER,
+ paymentHasBookingRequestId,
+} from '@telivityhaip/shared';
diff --git a/apps/api/src/modules/payment/interfaces/payment-gateway.interface.ts b/apps/api/src/modules/payment/interfaces/payment-gateway.interface.ts
index 340c423e..47c22826 100644
--- a/apps/api/src/modules/payment/interfaces/payment-gateway.interface.ts
+++ b/apps/api/src/modules/payment/interfaces/payment-gateway.interface.ts
@@ -1,39 +1,7 @@
-export interface PaymentGatewayResult {
- success: boolean;
- transactionId: string;
- errorMessage?: string;
-}
-
-/**
- * Optional per-call options. `idempotencyKey` is forwarded to the gateway
- * (Stripe supports `Idempotency-Key` on any mutating request) so that
- * retries of the same logical operation do not double-charge.
- */
-export interface PaymentGatewayCallOptions {
- idempotencyKey?: string;
-}
-
-export interface PaymentGateway {
- authorize(
- token: string,
- amount: number,
- currency: string,
- options?: PaymentGatewayCallOptions,
- ): Promise;
- capture(
- transactionId: string,
- amount?: number,
- options?: PaymentGatewayCallOptions,
- ): Promise;
- void(
- transactionId: string,
- options?: PaymentGatewayCallOptions,
- ): Promise;
- refund(
- transactionId: string,
- amount?: number,
- options?: PaymentGatewayCallOptions,
- ): Promise;
-}
-
-export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY');
+/** Canonical definition lives in @telivityhaip/shared (used by @telivityhaip/booking-requests too). */
+export {
+ type PaymentGateway,
+ type PaymentGatewayCallOptions,
+ type PaymentGatewayResult,
+ PAYMENT_GATEWAY,
+} from '@telivityhaip/shared';
diff --git a/apps/api/src/modules/payment/interfaces/saved-payment-method-gateway.interface.ts b/apps/api/src/modules/payment/interfaces/saved-payment-method-gateway.interface.ts
new file mode 100644
index 00000000..abff268d
--- /dev/null
+++ b/apps/api/src/modules/payment/interfaces/saved-payment-method-gateway.interface.ts
@@ -0,0 +1,9 @@
+/** Canonical definition lives in @telivityhaip/shared (used by @telivityhaip/booking-requests too). */
+export {
+ type SavedPaymentMethod,
+ type SavedPaymentMethodChargeInput,
+ type SavedPaymentMethodChargeResult,
+ type SavedPaymentMethodGateway,
+ type SavedPaymentMethodProvenance,
+ SAVED_PAYMENT_METHOD_GATEWAY,
+} from '@telivityhaip/shared';
diff --git a/apps/api/src/modules/payment/mock-saved-payment-method.gateway.spec.ts b/apps/api/src/modules/payment/mock-saved-payment-method.gateway.spec.ts
new file mode 100644
index 00000000..3224d0c3
--- /dev/null
+++ b/apps/api/src/modules/payment/mock-saved-payment-method.gateway.spec.ts
@@ -0,0 +1,203 @@
+import { MockSavedPaymentMethodGateway } from './mock-saved-payment-method.gateway';
+import { MODULE_METADATA } from '@nestjs/common/constants';
+import type { ConfigService } from '@nestjs/config';
+import {
+ SAVED_PAYMENT_METHOD_GATEWAY,
+ type SavedPaymentMethodGateway,
+} from './interfaces/saved-payment-method-gateway.interface';
+import { PaymentModule } from './payment.module';
+import { StripeSavedPaymentMethodGateway } from './stripe-saved-payment-method.gateway';
+
+describe('MockSavedPaymentMethodGateway', () => {
+ const provenance = {
+ propertyId: 'aaaaaaaa-0000-4000-a000-000000000001',
+ applicationId: 'submission-attempt-1',
+ };
+
+ it('creates a deterministic successful card setup that can be resolved', async () => {
+ const gateway = new MockSavedPaymentMethodGateway();
+
+ const first = await gateway.createSetup(
+ 'guest@example.com',
+ 'request-card:req_123',
+ provenance,
+ );
+ const retry = await gateway.createSetup(
+ 'guest@example.com',
+ 'request-card:req_123',
+ provenance,
+ );
+
+ expect(retry).toEqual(first);
+ await expect(gateway.resolveSetup(first.setupIntentId, provenance)).resolves.toEqual({
+ setupIntentId: first.setupIntentId,
+ customerId: first.customerId,
+ paymentMethodId: expect.stringMatching(/^pm_mock_/),
+ cardLastFour: '4242',
+ cardBrand: 'visa',
+ });
+ });
+
+ it('does not resolve a setup identifier it did not create', async () => {
+ const gateway = new MockSavedPaymentMethodGateway();
+
+ await expect(gateway.resolveSetup('seti_from_the_browser', provenance)).rejects.toThrow(
+ /Unknown mock SetupIntent/,
+ );
+ });
+
+ it('binds a setup to its property and application provenance', async () => {
+ const gateway = new MockSavedPaymentMethodGateway();
+ const setup = await gateway.createSetup(
+ 'guest@example.com',
+ 'request-card:req_scoped',
+ provenance,
+ );
+
+ await expect(gateway.resolveSetup(setup.setupIntentId, provenance)).resolves.toMatchObject({
+ setupIntentId: setup.setupIntentId,
+ });
+ await expect(gateway.resolveSetup(setup.setupIntentId, {
+ ...provenance,
+ propertyId: 'ffffffff-0000-4000-a000-000000000001',
+ })).rejects.toThrow(/provenance/i);
+ await expect(gateway.resolveSetup(setup.setupIntentId, {
+ ...provenance,
+ applicationId: 'submission-attempt-2',
+ })).rejects.toThrow(/provenance/i);
+ });
+
+ it('returns an idempotent successful off-session charge result', async () => {
+ const gateway = new MockSavedPaymentMethodGateway();
+ const input = {
+ customerId: 'cus_mock_trusted',
+ paymentMethodId: 'pm_mock_trusted',
+ paymentId: 'cccccccc-0000-4000-a000-000000000001',
+ propertyId: provenance.propertyId,
+ bookingRequestId: 'bbbbbbbb-0000-4000-a000-000000000001',
+ amount: '75.00',
+ currencyCode: 'EUR',
+ idempotencyKey: 'request-charge:payment_123',
+ };
+
+ const first = await gateway.charge(input);
+ const retry = await gateway.charge(input);
+
+ expect(first).toEqual({
+ success: true,
+ transactionId: expect.stringMatching(/^pi_mock_/),
+ requiresAction: false,
+ });
+ expect(retry).toEqual(first);
+
+ await expect(gateway.charge({
+ ...input,
+ paymentId: 'dddddddd-0000-4000-a000-000000000001',
+ })).rejects.toThrow(/idempotency.*different.*payment|identity/i);
+ });
+});
+
+describe('PaymentModule saved-payment-method registration', () => {
+ type GatewayProvider = {
+ provide: symbol;
+ useFactory: (configService: ConfigService) => SavedPaymentMethodGateway;
+ };
+
+ function gatewayProvider(): GatewayProvider {
+ const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, PaymentModule) as unknown[];
+ const provider = providers.find(
+ (candidate): candidate is GatewayProvider =>
+ typeof candidate === 'object' &&
+ candidate !== null &&
+ 'provide' in candidate &&
+ candidate.provide === SAVED_PAYMENT_METHOD_GATEWAY,
+ );
+ if (!provider) throw new Error('Saved payment method provider is not registered');
+ return provider;
+ }
+
+ it('exports the saved-method injection seam', () => {
+ const exports = Reflect.getMetadata(MODULE_METADATA.EXPORTS, PaymentModule) as unknown[];
+
+ expect(exports).toContain(SAVED_PAYMENT_METHOD_GATEWAY);
+ });
+
+ it('selects mock and Stripe adapters without changing the existing gateway', () => {
+ const provider = gatewayProvider();
+ const mockConfig = {
+ get: (key: string, fallback?: string) => key === 'STRIPE_MODE' ? 'mock' : fallback,
+ } as ConfigService;
+ const stripeConfig = {
+ get: (key: string, fallback?: string) => {
+ if (key === 'STRIPE_MODE') return 'test';
+ if (key === 'STRIPE_SECRET_KEY') return 'sk_test_saved_method';
+ return fallback;
+ },
+ } as ConfigService;
+
+ expect(provider.useFactory(mockConfig)).toBeInstanceOf(MockSavedPaymentMethodGateway);
+ expect(provider.useFactory(stripeConfig)).toBeInstanceOf(StripeSavedPaymentMethodGateway);
+ });
+
+ it('honors the existing PAYMENT_GATEWAY override when selecting saved-method mode', () => {
+ const provider = gatewayProvider();
+ const mockOverride = {
+ get: (key: string, fallback?: string) => {
+ if (key === 'PAYMENT_GATEWAY') return 'mock';
+ if (key === 'STRIPE_MODE') return 'live';
+ return fallback;
+ },
+ } as ConfigService;
+ const stripeOverride = {
+ get: (key: string, fallback?: string) => {
+ if (key === 'PAYMENT_GATEWAY') return 'stripe';
+ if (key === 'STRIPE_MODE') return 'mock';
+ if (key === 'STRIPE_SECRET_KEY') return 'sk_test_saved_method';
+ return fallback;
+ },
+ } as ConfigService;
+
+ expect(provider.useFactory(mockOverride)).toBeInstanceOf(MockSavedPaymentMethodGateway);
+ expect(provider.useFactory(stripeOverride)).toBeInstanceOf(StripeSavedPaymentMethodGateway);
+ });
+
+ it.each(['adyen', 'mollie', 'square', 'braintree', 'wise'])(
+ 'preserves %s startup without constructing a Stripe saved-method adapter',
+ (paymentProvider) => {
+ const provider = gatewayProvider();
+ const alternativeConfig = {
+ get: (key: string, fallback?: string) =>
+ key === 'PAYMENT_GATEWAY' ? paymentProvider : fallback,
+ } as ConfigService;
+
+ expect(() => provider.useFactory(alternativeConfig)).not.toThrow();
+ },
+ );
+
+ it('rejects every saved-method operation clearly for an unsupported provider', async () => {
+ const provider = gatewayProvider();
+ const alternativeConfig = {
+ get: (key: string, fallback?: string) =>
+ key === 'PAYMENT_GATEWAY' ? 'adyen' : fallback,
+ } as ConfigService;
+ const gateway = provider.useFactory(alternativeConfig);
+
+ const provenance = { propertyId: 'property-test', applicationId: 'application-test' };
+ await expect(gateway.createSetup('guest@example.com', 'setup-key', provenance)).rejects.toThrow(
+ /Saved payment methods are not supported.*adyen/,
+ );
+ await expect(gateway.resolveSetup('seti_test', provenance)).rejects.toThrow(
+ /Saved payment methods are not supported.*adyen/,
+ );
+ await expect(gateway.charge({
+ customerId: 'cus_test',
+ paymentMethodId: 'pm_test',
+ paymentId: 'cccccccc-0000-4000-a000-000000000001',
+ propertyId: 'aaaaaaaa-0000-4000-a000-000000000001',
+ bookingRequestId: 'bbbbbbbb-0000-4000-a000-000000000001',
+ amount: '10.00',
+ currencyCode: 'USD',
+ idempotencyKey: 'charge-key',
+ })).rejects.toThrow(/Saved payment methods are not supported.*adyen/);
+ });
+});
diff --git a/apps/api/src/modules/payment/mock-saved-payment-method.gateway.ts b/apps/api/src/modules/payment/mock-saved-payment-method.gateway.ts
new file mode 100644
index 00000000..33ec838e
--- /dev/null
+++ b/apps/api/src/modules/payment/mock-saved-payment-method.gateway.ts
@@ -0,0 +1,133 @@
+import { Injectable } from '@nestjs/common';
+import { createHash } from 'crypto';
+import type {
+ SavedPaymentMethod,
+ SavedPaymentMethodChargeInput,
+ SavedPaymentMethodChargeResult,
+ SavedPaymentMethodGateway,
+ SavedPaymentMethodProvenance,
+} from './interfaces/saved-payment-method-gateway.interface';
+
+type MockSetupRecord = {
+ setup: {
+ setupIntentId: string;
+ clientSecret: string;
+ customerId: string;
+ clientMode: 'mock';
+ };
+ paymentMethod: SavedPaymentMethod;
+ propertyId: string;
+ applicationHash: string;
+};
+
+type MockChargeRecord = {
+ result: SavedPaymentMethodChargeResult;
+ paymentId: string;
+ propertyId: string;
+ bookingRequestId: string;
+};
+
+@Injectable()
+export class MockSavedPaymentMethodGateway implements SavedPaymentMethodGateway {
+ private readonly setupsByKey = new Map();
+ private readonly setupsBySetupId = new Map();
+ private readonly chargesByKey = new Map();
+
+ async createSetup(
+ _email: string,
+ idempotencyKey: string,
+ provenance: SavedPaymentMethodProvenance,
+ ): Promise<{
+ setupIntentId: string;
+ clientSecret: string;
+ customerId: string;
+ clientMode: 'mock';
+ }> {
+ const existing = this.setupsByKey.get(idempotencyKey);
+ if (existing) {
+ this.assertProvenance(existing, provenance);
+ return existing.setup;
+ }
+
+ const suffix = this.stableSuffix(idempotencyKey);
+ const setup = {
+ setupIntentId: `seti_mock_${suffix}`,
+ clientSecret: `seti_mock_${suffix}_secret_mock`,
+ customerId: `cus_mock_${suffix}`,
+ clientMode: 'mock' as const,
+ };
+ const paymentMethod: SavedPaymentMethod = {
+ setupIntentId: setup.setupIntentId,
+ customerId: setup.customerId,
+ paymentMethodId: `pm_mock_${suffix}`,
+ cardLastFour: '4242',
+ cardBrand: 'visa',
+ };
+ const record = {
+ setup,
+ paymentMethod,
+ propertyId: provenance.propertyId,
+ applicationHash: this.stableHash(provenance.applicationId),
+ };
+ this.setupsByKey.set(idempotencyKey, record);
+ this.setupsBySetupId.set(setup.setupIntentId, record);
+ return setup;
+ }
+
+ async resolveSetup(
+ setupIntentId: string,
+ expectedProvenance: SavedPaymentMethodProvenance,
+ ): Promise {
+ const record = this.setupsBySetupId.get(setupIntentId);
+ if (!record) {
+ throw new Error(`Unknown mock SetupIntent '${setupIntentId}'`);
+ }
+ this.assertProvenance(record, expectedProvenance);
+ return record.paymentMethod;
+ }
+
+ async charge(input: SavedPaymentMethodChargeInput): Promise {
+ const existing = this.chargesByKey.get(input.idempotencyKey);
+ if (existing) {
+ if (existing.paymentId !== input.paymentId
+ || existing.propertyId !== input.propertyId
+ || existing.bookingRequestId !== input.bookingRequestId) {
+ throw new Error('Mock charge idempotency key was reused for a different payment identity');
+ }
+ return existing.result;
+ }
+
+ const result = {
+ success: true,
+ transactionId: `pi_mock_${this.stableSuffix(input.idempotencyKey)}`,
+ requiresAction: false,
+ } satisfies SavedPaymentMethodChargeResult;
+ this.chargesByKey.set(input.idempotencyKey, {
+ result,
+ paymentId: input.paymentId,
+ propertyId: input.propertyId,
+ bookingRequestId: input.bookingRequestId,
+ });
+ return result;
+ }
+
+ private stableSuffix(value: string): string {
+ return this.stableHash(value).slice(0, 24);
+ }
+
+ private stableHash(value: string): string {
+ return createHash('sha256').update(value).digest('hex');
+ }
+
+ private assertProvenance(
+ record: Pick,
+ expected: SavedPaymentMethodProvenance,
+ ): void {
+ if (
+ record.propertyId !== expected.propertyId
+ || record.applicationHash !== this.stableHash(expected.applicationId)
+ ) {
+ throw new Error('Mock SetupIntent provenance does not match');
+ }
+ }
+}
diff --git a/apps/api/src/modules/payment/payment-ledger.spec.ts b/apps/api/src/modules/payment/payment-ledger.spec.ts
index 3e3dcc88..a2403ba7 100644
--- a/apps/api/src/modules/payment/payment-ledger.spec.ts
+++ b/apps/api/src/modules/payment/payment-ledger.spec.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { sumRefundChildren } from './payment-ledger';
+import { remainingCapturedAmount, sumRefundChildren } from './payment-ledger';
describe('payment-ledger', () => {
describe('sumRefundChildren', () => {
@@ -15,4 +15,11 @@ describe('payment-ledger', () => {
expect(sumRefundChildren([]).toFixed(2)).toBe('0.00');
});
});
+
+ it('calculates exact remaining captured money across partial child movements', () => {
+ expect(remainingCapturedAmount('100.00', [
+ { amount: '-30.10' },
+ { amount: '-19.90' },
+ ]).toFixed(2)).toBe('50.00');
+ });
});
diff --git a/apps/api/src/modules/payment/payment-ledger.ts b/apps/api/src/modules/payment/payment-ledger.ts
index eeef8dc6..fa1acd24 100644
--- a/apps/api/src/modules/payment/payment-ledger.ts
+++ b/apps/api/src/modules/payment/payment-ledger.ts
@@ -1,6 +1,8 @@
import { and, eq, inArray, isNull, or, type SQL } from 'drizzle-orm';
import { payments } from '@telivityhaip/database';
-import Decimal from 'decimal.js';
+
+/** Canonical definitions live in @telivityhaip/shared (used by @telivityhaip/booking-requests too). */
+export { remainingCapturedAmount, sumRefundChildren } from '@telivityhaip/shared';
/**
* Net folio / cash-report payment ledger:
@@ -42,9 +44,13 @@ export function folioPaymentSumWhere(
)!;
}
-/** Payment rows that count toward property-scoped cash reports (same net model). */
-export function reportPaymentSumWhere(propertyId: string): SQL {
+/** Payment rows that count toward a pre/post-acceptance Booking Request net. */
+export function bookingRequestPaymentSumWhere(
+ bookingRequestId: string,
+ propertyId: string,
+): SQL {
return and(
+ eq(payments.bookingRequestId, bookingRequestId),
eq(payments.propertyId, propertyId),
or(
eq(payments.status, 'captured'),
@@ -56,12 +62,16 @@ export function reportPaymentSumWhere(propertyId: string): SQL {
)!;
}
-/** Sum refund / correction child rows already posted against a parent payment. */
-export function sumRefundChildren(
- rows: Array<{ amount: string | number }>,
-): Decimal {
- return rows.reduce(
- (sum, r) => sum.plus(new Decimal(r.amount).abs()),
- new Decimal(0),
- );
+/** Payment rows that count toward property-scoped cash reports (same net model). */
+export function reportPaymentSumWhere(propertyId: string): SQL {
+ return and(
+ eq(payments.propertyId, propertyId),
+ or(
+ eq(payments.status, 'captured'),
+ and(
+ isNull(payments.originalPaymentId),
+ inArray(payments.status, [...FOLIO_PARENT_PAYMENT_STATUSES]),
+ ),
+ ),
+ )!;
}
diff --git a/apps/api/src/modules/payment/payment-legacy-seam.spec.ts b/apps/api/src/modules/payment/payment-legacy-seam.spec.ts
new file mode 100644
index 00000000..7e8d70d3
--- /dev/null
+++ b/apps/api/src/modules/payment/payment-legacy-seam.spec.ts
@@ -0,0 +1,133 @@
+import { Reflector } from '@nestjs/core';
+import { describe, expect, it, vi } from 'vitest';
+import { ROLES_KEY } from '../auth/roles.decorator';
+import { PaymentController } from './payment.controller';
+import { PaymentService } from './payment.service';
+
+const requestPayment = {
+ id: 'dddddddd-0000-4000-a000-000000000001',
+ propertyId: 'aaaaaaaa-0000-4000-a000-000000000001',
+ bookingRequestId: 'bbbbbbbb-0000-4000-a000-000000000001',
+ folioId: null,
+ houseAccountId: null,
+ idempotencyKey: 'booking-request-charge:secret-fingerprint',
+ method: 'credit_card',
+ status: 'captured',
+ amount: '100.00',
+ currencyCode: 'EUR',
+ gatewayProvider: 'stripe',
+ gatewayTransactionId: 'pi_public_receipt',
+ gatewayPaymentToken: 'pm_secret_saved_method',
+ cardLastFour: '4242',
+ cardBrand: 'visa',
+ originalPaymentId: null,
+ notes: 'safe note',
+ processedAt: new Date('2026-08-24T10:00:00.000Z'),
+ createdAt: new Date('2026-08-24T09:00:00.000Z'),
+ updatedAt: new Date('2026-08-24T10:00:00.000Z'),
+};
+
+function dbReturning(row = requestPayment) {
+ const selection = () => {
+ const whereResult: Record & PromiseLike = {
+ for: vi.fn().mockResolvedValue([row]),
+ limit: vi.fn(() => ({
+ offset: vi.fn(() => ({ orderBy: vi.fn().mockResolvedValue([row]) })),
+ })),
+ then: (resolve: (value: unknown) => unknown) => Promise.resolve([row]).then(resolve),
+ };
+ return {
+ from: vi.fn(() => ({ where: vi.fn(() => whereResult) })),
+ };
+ };
+ const mutation = () => ({
+ set: vi.fn(() => ({
+ where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([row]) })),
+ })),
+ });
+ const tx = {
+ select: vi.fn(selection),
+ insert: vi.fn(() => ({
+ values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([{ ...row, amount: '-100.00' }]) })),
+ })),
+ };
+ return {
+ select: vi.fn(selection),
+ update: vi.fn(mutation),
+ transaction: vi.fn(async (callback: (value: typeof tx) => Promise) => callback(tx)),
+ };
+}
+
+function serviceWith(db: ReturnType) {
+ return new (PaymentService as any)(
+ db,
+ { recalculateBalance: vi.fn(), postCharge: vi.fn() },
+ {
+ capture: vi.fn().mockResolvedValue({ success: true, transactionId: 'cap' }),
+ void: vi.fn().mockResolvedValue({ success: true, transactionId: 'void' }),
+ refund: vi.fn().mockResolvedValue({ success: true, transactionId: 'refund' }),
+ },
+ { emit: vi.fn() },
+ ) as PaymentService;
+}
+
+describe('legacy payment HTTP seam', () => {
+ it('uses role guards for generic payment mutations', () => {
+ const reflector = new Reflector();
+ for (const method of [
+ 'recordPayment',
+ 'authorizePayment',
+ 'capturePayment',
+ 'voidPayment',
+ 'refundPayment',
+ 'correctPayment',
+ ] as const) {
+ expect(reflector.get(
+ ROLES_KEY,
+ PaymentController.prototype[method],
+ )).toEqual(['admin', 'general_manager', 'front_desk', 'reservations']);
+ }
+ });
+
+ it('maps generic reads to an explicit safe payment response', async () => {
+ const legacyPayment = {
+ ...requestPayment,
+ bookingRequestId: null,
+ folioId: 'cccccccc-0000-4000-a000-000000000001',
+ };
+ const service = serviceWith(dbReturning(legacyPayment));
+
+ const result = await service.findById(legacyPayment.id, legacyPayment.propertyId);
+
+ expect(result).toMatchObject({
+ id: requestPayment.id,
+ bookingRequestId: null,
+ amount: '100.00',
+ });
+ expect(result).not.toHaveProperty('gatewayPaymentToken');
+ expect(result).not.toHaveProperty('idempotencyKey');
+ expect(result).not.toHaveProperty('gatewayTransactionId');
+ expect(result).not.toHaveProperty('fingerprint');
+ });
+
+ it('rejects a request-targeted read through the generic payment endpoint', async () => {
+ const service = serviceWith(dbReturning());
+
+ await expect(service.findById(requestPayment.id, requestPayment.propertyId))
+ .rejects.toThrow(/Booking Request payment endpoint/i);
+ });
+
+ it('rejects every generic mutation of a request-targeted payment', async () => {
+ const service = serviceWith(dbReturning());
+ const expected = /booking request payment endpoint/i;
+
+ await expect(service.capturePayment(requestPayment.id, requestPayment.propertyId))
+ .rejects.toThrow(expected);
+ await expect(service.voidPayment(requestPayment.id, requestPayment.propertyId))
+ .rejects.toThrow(expected);
+ await expect(service.refundPayment(requestPayment.id, requestPayment.propertyId, '10.00'))
+ .rejects.toThrow(expected);
+ await expect(service.correctPayment(requestPayment.id, requestPayment.propertyId))
+ .rejects.toThrow(expected);
+ });
+});
diff --git a/apps/api/src/modules/payment/payment.module.ts b/apps/api/src/modules/payment/payment.module.ts
index 25da1da5..b6176201 100644
--- a/apps/api/src/modules/payment/payment.module.ts
+++ b/apps/api/src/modules/payment/payment.module.ts
@@ -6,7 +6,26 @@ import { PaymentController } from './payment.controller';
import { StripeWebhookController } from './stripe-webhook.controller';
import { PaymentService } from './payment.service';
import { PAYMENT_GATEWAY } from './interfaces/payment-gateway.interface';
-import { createPaymentGateway } from './payment-gateway.factory';
+import {
+ createPaymentGateway,
+ resolvePaymentGatewayProvider,
+} from './payment-gateway.factory';
+import { SAVED_PAYMENT_METHOD_GATEWAY } from './interfaces/saved-payment-method-gateway.interface';
+import { MockSavedPaymentMethodGateway } from './mock-saved-payment-method.gateway';
+import { StripeSavedPaymentMethodGateway } from './stripe-saved-payment-method.gateway';
+import { UnsupportedSavedPaymentMethodGateway } from './unsupported-saved-payment-method.gateway';
+
+function createSavedPaymentMethodGateway(configService: ConfigService) {
+ const provider = resolvePaymentGatewayProvider(configService);
+ switch (provider) {
+ case 'mock':
+ return new MockSavedPaymentMethodGateway();
+ case 'stripe':
+ return new StripeSavedPaymentMethodGateway(configService);
+ default:
+ return new UnsupportedSavedPaymentMethodGateway(provider);
+ }
+}
/**
* Payment module with configurable gateway.
@@ -29,7 +48,13 @@ import { createPaymentGateway } from './payment-gateway.factory';
useFactory: (configService: ConfigService) => createPaymentGateway(configService),
inject: [ConfigService],
},
+ {
+ provide: SAVED_PAYMENT_METHOD_GATEWAY,
+ useFactory: (configService: ConfigService) =>
+ createSavedPaymentMethodGateway(configService),
+ inject: [ConfigService],
+ },
],
- exports: [PaymentService],
+ exports: [PaymentService, PAYMENT_GATEWAY, SAVED_PAYMENT_METHOD_GATEWAY],
})
export class PaymentModule {}
diff --git a/apps/api/src/modules/payment/payment.service.spec.ts b/apps/api/src/modules/payment/payment.service.spec.ts
index e5b6e232..c7403c32 100644
--- a/apps/api/src/modules/payment/payment.service.spec.ts
+++ b/apps/api/src/modules/payment/payment.service.spec.ts
@@ -77,6 +77,13 @@ const mockGateway = {
const mockWebhookService = { emit: vi.fn() };
+function expectSafePublicPayment(value: Record) {
+ expect(value).not.toHaveProperty('gatewayPaymentToken');
+ expect(value).not.toHaveProperty('gatewayTransactionId');
+ expect(value).not.toHaveProperty('idempotencyKey');
+ expect(value).not.toHaveProperty('fingerprint');
+}
+
describe('PaymentService', () => {
let service: PaymentService;
let mockDb: ReturnType;
@@ -109,7 +116,8 @@ describe('PaymentService', () => {
currencyCode: 'USD',
});
- expect(result).toEqual(mockPayment);
+ expect(result).toMatchObject({ id: mockPayment.id, status: mockPayment.status });
+ expectSafePublicPayment(result);
expect(mockFolioService.recalculateBalance).toHaveBeenCalledWith('folio-001', 'prop-001');
expect(mockWebhookService.emit).toHaveBeenCalledWith(
'payment.received',
@@ -129,7 +137,7 @@ describe('PaymentService', () => {
currencyCode: 'BRL',
});
- expect(result).toEqual(mockPayment);
+ expect(result).toMatchObject({ id: mockPayment.id, status: mockPayment.status });
expect(mockFolioService.recalculateBalance).toHaveBeenCalledWith('folio-001', 'prop-001');
});
@@ -142,7 +150,7 @@ describe('PaymentService', () => {
currencyCode: 'BRL',
});
- expect(result).toEqual(mockPayment);
+ expect(result).toMatchObject({ id: mockPayment.id, status: mockPayment.status });
expect(mockWebhookService.emit).toHaveBeenCalledWith(
'payment.received',
'payment',
@@ -300,6 +308,7 @@ describe('PaymentService', () => {
expect(mockGateway.authorize).toHaveBeenCalledWith('tok_test_123', 500, 'USD');
expect(result.status).toBe('authorized');
+ expectSafePublicPayment(result);
// Pre-auth does NOT recalculate balance
expect(mockFolioService.recalculateBalance).not.toHaveBeenCalled();
});
@@ -387,6 +396,7 @@ describe('PaymentService', () => {
const result = await svc.capturePayment('pay-001', 'prop-001');
expect(result.status).toBe('captured');
+ expectSafePublicPayment(result);
expect(mockGateway.capture).toHaveBeenCalled();
expect(mockFolioService.recalculateBalance).toHaveBeenCalled();
});
@@ -467,6 +477,7 @@ describe('PaymentService', () => {
const result = await svc.voidPayment('pay-001', 'prop-001');
expect(result.status).toBe('voided');
+ expectSafePublicPayment(result);
expect(mockGateway.void).toHaveBeenCalled();
});
});
@@ -526,7 +537,107 @@ describe('PaymentService', () => {
expect.any(Object),
'prop-001',
);
- expect(result).toEqual(refundPayment);
+ expect(result).toMatchObject({ id: refundPayment.id, amount: refundPayment.amount });
+ expectSafePublicPayment(result);
+ });
+
+ it('rejects a Booking Request refund through the generic service', async () => {
+ const requestPayment = {
+ ...mockPayment,
+ folioId: null,
+ bookingRequestId: 'request-001',
+ status: 'captured',
+ };
+ const insert = vi.fn();
+ const makeTx = () => {
+ let selectCall = 0;
+ return {
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => {
+ selectCall += 1;
+ return selectCall === 1
+ ? { for: vi.fn().mockResolvedValue([requestPayment]) }
+ : { then: (resolve: any) => resolve([]) };
+ }),
+ })),
+ })),
+ insert,
+ };
+ };
+ const db = {
+ transaction: vi.fn(async (fn: any) => fn(makeTx())),
+ };
+ const module = await Test.createTestingModule({
+ providers: [
+ PaymentService,
+ { provide: DRIZZLE, useValue: db },
+ { provide: FolioService, useValue: mockFolioService },
+ { provide: PAYMENT_GATEWAY, useValue: mockGateway },
+ { provide: WebhookService, useValue: mockWebhookService },
+ ],
+ }).compile();
+
+ await expect(module.get(PaymentService).refundPayment(
+ 'pay-001',
+ 'prop-001',
+ '25.00',
+ )).rejects.toThrow(/Booking Request payment endpoint/i);
+
+ expect(insert).not.toHaveBeenCalled();
+ expect(mockFolioService.recalculateBalance).not.toHaveBeenCalled();
+ });
+
+ it('replays an explicitly idempotent refund without another gateway call', async () => {
+ const original = {
+ ...mockPayment,
+ amount: '100.00',
+ status: 'captured',
+ };
+ const existingRefund = {
+ ...mockPayment,
+ id: 'refund-existing',
+ amount: '-30.00',
+ originalPaymentId: 'pay-001',
+ idempotencyKey: 'booking-request-refund:stable',
+ };
+ const tx = {
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ for: vi.fn().mockResolvedValue([original]),
+ then: (resolve: any) => resolve([existingRefund]),
+ })),
+ })),
+ })),
+ };
+ const db = {
+ transaction: vi.fn(async (fn: any) => fn(tx)),
+ };
+ const module = await Test.createTestingModule({
+ providers: [
+ PaymentService,
+ { provide: DRIZZLE, useValue: db },
+ { provide: FolioService, useValue: mockFolioService },
+ { provide: PAYMENT_GATEWAY, useValue: mockGateway },
+ { provide: WebhookService, useValue: mockWebhookService },
+ ],
+ }).compile();
+
+ const result = await (module.get(PaymentService).refundPayment as any)(
+ 'pay-001',
+ 'prop-001',
+ '30.00',
+ { idempotencyKey: 'booking-request-refund:stable' },
+ );
+
+ expect(result).toMatchObject({
+ id: existingRefund.id,
+ amount: existingRefund.amount,
+ originalPaymentId: existingRefund.originalPaymentId,
+ });
+ expectSafePublicPayment(result);
+ expect(mockGateway.refund).not.toHaveBeenCalled();
});
// Partial refunds: parent stays captured; negative children net the folio balance.
@@ -679,7 +790,12 @@ describe('PaymentService', () => {
const db = buildRefundTxDb(capturedOriginal, [], [webhookChild], webhookChild);
const svc = await svcWith(db);
const result = await svc.refundPayment('pay-001', 'prop-001', '50.00');
- expect(result).toEqual(webhookChild);
+ expect(result).toMatchObject({
+ id: webhookChild.id,
+ amount: webhookChild.amount,
+ originalPaymentId: webhookChild.originalPaymentId,
+ });
+ expectSafePublicPayment(result);
expect(mockWebhookService.emit).not.toHaveBeenCalled();
expect(mockFolioService.recalculateBalance).not.toHaveBeenCalled();
});
diff --git a/apps/api/src/modules/payment/payment.service.ts b/apps/api/src/modules/payment/payment.service.ts
index 7579344c..43f788fa 100644
--- a/apps/api/src/modules/payment/payment.service.ts
+++ b/apps/api/src/modules/payment/payment.service.ts
@@ -5,7 +5,7 @@ import {
BadRequestException,
ConflictException,
} from '@nestjs/common';
-import { eq, and, sql } from 'drizzle-orm';
+import { eq, and, isNull, sql } from 'drizzle-orm';
import { Decimal } from 'decimal.js';
import { payments } from '@telivityhaip/database';
import { DRIZZLE } from '../../database/database.module';
@@ -20,6 +20,11 @@ import { sumRefundChildren, parentCountsTowardFolioBalance } from './payment-led
const CARD_METHODS = ['credit_card', 'debit_card', 'vcc'];
+export type RefundPaymentOptions = {
+ /** Stable logical refund identity used for crash-safe provider/ledger replay. */
+ idempotencyKey?: string;
+};
+
@Injectable()
export class PaymentService {
constructor(
@@ -97,7 +102,7 @@ export class PaymentService {
dto.propertyId,
);
- return payment;
+ return this.safePaymentResponse(payment);
}
async authorizePayment(dto: AuthorizePaymentDto) {
@@ -177,7 +182,7 @@ export class PaymentService {
dto.propertyId,
);
- return payment;
+ return this.safePaymentResponse(payment);
}
/**
@@ -194,6 +199,8 @@ export class PaymentService {
* idempotency key provides a second line of defense if a retry slips past.
*/
async capturePayment(id: string, propertyId: string) {
+ const target = await this.findPaymentRow(id, propertyId);
+ this.assertGenericAccessAllowed(target);
// Phase 1: atomically claim the payment (authorized → captured)
const [claimed] = await this.db
.update(payments)
@@ -230,7 +237,7 @@ export class PaymentService {
const result = await this.gateway.capture(
claimed.gatewayTransactionId,
new Decimal(claimed.amount).toNumber(),
- { idempotencyKey: `cap_${id}` },
+ { idempotencyKey: `cap_${id}`, currencyCode: claimed.currencyCode },
);
if (!result.success) {
@@ -252,13 +259,15 @@ export class PaymentService {
propertyId,
);
- return claimed;
+ return this.safePaymentResponse(claimed);
}
/**
* Void an authorized payment. Same two-phase concurrency-safe pattern as capture.
*/
async voidPayment(id: string, propertyId: string) {
+ const target = await this.findPaymentRow(id, propertyId);
+ this.assertGenericAccessAllowed(target);
// Phase 1: atomically claim the payment (authorized → voided)
const [claimed] = await this.db
.update(payments)
@@ -305,7 +314,7 @@ export class PaymentService {
propertyId,
);
- return claimed;
+ return this.safePaymentResponse(claimed);
}
/**
@@ -314,7 +323,12 @@ export class PaymentService {
* Parent row stays `captured` (or `settled`); net folio effect comes from a
* negative child row. Row lock serializes concurrent partial refunds.
*/
- async refundPayment(id: string, propertyId: string, amount?: string) {
+ async refundPayment(
+ id: string,
+ propertyId: string,
+ amount?: string,
+ options: RefundPaymentOptions = {},
+ ) {
const prepared = await this.db.transaction(async (tx: any) => {
const [original] = await tx
.select()
@@ -326,6 +340,8 @@ export class PaymentService {
throw new NotFoundException(`Payment ${id} not found`);
}
+ this.assertGenericAccessAllowed(original);
+
if (!['captured', 'settled', 'partially_refunded'].includes(original.status)) {
throw new BadRequestException(
`Cannot refund payment with status '${original.status}'`,
@@ -338,7 +354,40 @@ export class PaymentService {
if (refundAmountInTx.lte(0)) {
throw new BadRequestException('Refund amount must be positive');
}
+ if (!original.gatewayTransactionId) {
+ throw new BadRequestException(
+ `Payment ${id} has no gateway transaction to refund`,
+ );
+ }
+ if (options.idempotencyKey) {
+ const replayRows = await tx
+ .select()
+ .from(payments)
+ .where(and(
+ eq(payments.propertyId, propertyId),
+ eq(payments.idempotencyKey, options.idempotencyKey),
+ ));
+ const replay = (replayRows ?? []).find((row: typeof payments.$inferSelect) =>
+ row.propertyId === propertyId
+ && row.idempotencyKey === options.idempotencyKey);
+ if (replay) {
+ if (
+ replay.originalPaymentId !== id
+ || !new Decimal(replay.amount).abs().eq(refundAmountInTx)
+ ) {
+ throw new ConflictException(
+ 'Refund idempotency key was already used for different refund data',
+ );
+ }
+ return {
+ original,
+ totalAfterDec: new Decimal(original.amount),
+ refundAmountDec: refundAmountInTx,
+ replay,
+ };
+ }
+ }
const existingRefunds = await tx
.select()
.from(payments)
@@ -362,16 +411,28 @@ export class PaymentService {
}
const totalAfterDec = alreadyRefundedDec.plus(refundAmountInTx);
- return { original, totalAfterDec, refundAmountDec: refundAmountInTx };
+ return {
+ original,
+ totalAfterDec,
+ refundAmountDec: refundAmountInTx,
+ replay: undefined,
+ };
});
- const { original, totalAfterDec, refundAmountDec: refundDec } = prepared;
+ const {
+ original,
+ totalAfterDec,
+ refundAmountDec: refundDec,
+ replay,
+ } = prepared;
+ if (replay) return this.safePaymentResponse(replay);
- const idempotencyKey = `ref_${id}_${totalAfterDec.toFixed(2)}`;
+ const idempotencyKey = options.idempotencyKey
+ ?? `ref_${id}_${totalAfterDec.toFixed(2)}`;
const result = await this.gateway.refund(
original.gatewayTransactionId,
refundDec.toNumber(),
- { idempotencyKey },
+ { idempotencyKey, currencyCode: original.currencyCode },
);
if (!result.success) {
@@ -400,6 +461,32 @@ export class PaymentService {
);
const alreadyRefundedDec = sumRefundChildren(existingRefunds ?? []);
+ if (options.idempotencyKey) {
+ const replayRows = await tx
+ .select()
+ .from(payments)
+ .where(and(
+ eq(payments.propertyId, propertyId),
+ eq(payments.idempotencyKey, options.idempotencyKey),
+ ));
+ const replayAfterGateway = (replayRows ?? []).find(
+ (row: typeof payments.$inferSelect) =>
+ row.propertyId === propertyId
+ && row.idempotencyKey === options.idempotencyKey,
+ );
+ if (replayAfterGateway) {
+ if (
+ replayAfterGateway.originalPaymentId !== id
+ || !new Decimal(replayAfterGateway.amount).abs().eq(refundDec)
+ ) {
+ throw new ConflictException(
+ 'Refund idempotency key was already used for different refund data',
+ );
+ }
+ return { row: replayAfterGateway, isNew: false };
+ }
+ }
+
if (result.transactionId) {
const existingByGateway = (existingRefunds ?? []).find(
(r: any) => r.gatewayTransactionId === result.transactionId,
@@ -433,6 +520,8 @@ export class PaymentService {
.values({
folioId: locked.folioId,
propertyId,
+ bookingRequestId: locked.bookingRequestId,
+ idempotencyKey: options.idempotencyKey ?? null,
method: locked.method,
amount: ledgerRefundDec.negated().toFixed(2),
currencyCode: locked.currencyCode,
@@ -449,7 +538,9 @@ export class PaymentService {
});
if (refund.isNew) {
- await this.folioService.recalculateBalance(original.folioId, propertyId);
+ if (original.folioId) {
+ await this.folioService.recalculateBalance(original.folioId, propertyId);
+ }
await this.webhookService.emit(
'payment.refunded',
@@ -460,7 +551,7 @@ export class PaymentService {
);
}
- return refund.row;
+ return this.safePaymentResponse(refund.row);
}
/**
@@ -482,7 +573,8 @@ export class PaymentService {
propertyId: string,
opOverride?: 'void' | 'refund' | 'adjust',
) {
- const payment = await this.findById(id, propertyId);
+ const payment = await this.findPaymentRow(id, propertyId);
+ this.assertGenericAccessAllowed(payment);
const CASH_VOID_WINDOW_MS = 24 * 60 * 60 * 1000;
const isGatewayCard =
@@ -557,7 +649,7 @@ export class PaymentService {
{ folioId: voided.folioId, status: 'voided' },
propertyId,
);
- result = voided;
+ result = this.safePaymentResponse(voided);
}
await this.webhookService.emit(
'payment.corrected',
@@ -653,10 +745,16 @@ export class PaymentService {
{ op: 'adjust', method: payment.method, adjustmentAmount: adjustment.adjustmentAmount },
propertyId,
);
- return { op: 'adjust', adjustment: adjustment.row };
+ return { op: 'adjust', adjustment: this.safePaymentResponse(adjustment.row) };
}
async findById(id: string, propertyId: string) {
+ const payment = await this.findPaymentRow(id, propertyId);
+ this.assertGenericAccessAllowed(payment);
+ return this.safePaymentResponse(payment);
+ }
+
+ private async findPaymentRow(id: string, propertyId: string) {
const [payment] = await this.db
.select()
.from(payments)
@@ -667,8 +765,45 @@ export class PaymentService {
return payment;
}
+ private assertGenericAccessAllowed(
+ payment: typeof payments.$inferSelect,
+ ): void {
+ if (payment.bookingRequestId) {
+ throw new ConflictException(
+ 'Request-targeted payments must be accessed or changed through the Booking Request payment endpoint',
+ );
+ }
+ }
+
+ private safePaymentResponse(payment: typeof payments.$inferSelect) {
+ return {
+ id: payment.id,
+ propertyId: payment.propertyId,
+ folioId: payment.folioId,
+ houseAccountId: payment.houseAccountId,
+ bookingRequestId: payment.bookingRequestId,
+ method: payment.method,
+ status: payment.status,
+ amount: payment.amount,
+ currencyCode: payment.currencyCode,
+ gatewayProvider: payment.gatewayProvider,
+ cardLastFour: payment.cardLastFour,
+ cardBrand: payment.cardBrand,
+ isPreAuthorization: payment.isPreAuthorization,
+ preAuthExpiresAt: payment.preAuthExpiresAt,
+ originalPaymentId: payment.originalPaymentId,
+ notes: payment.notes,
+ processedAt: payment.processedAt,
+ createdAt: payment.createdAt,
+ updatedAt: payment.updatedAt,
+ };
+ }
+
async list(dto: ListPaymentsDto) {
- const conditions: any[] = [eq(payments.propertyId, dto.propertyId)];
+ const conditions: any[] = [
+ eq(payments.propertyId, dto.propertyId),
+ isNull(payments.bookingRequestId),
+ ];
if (dto.folioId) conditions.push(eq(payments.folioId, dto.folioId));
if (dto.status) conditions.push(eq(payments.status, dto.status as any));
@@ -694,7 +829,8 @@ export class PaymentService {
]);
return {
- data,
+ data: data.map((payment: typeof payments.$inferSelect) =>
+ this.safePaymentResponse(payment)),
total: Number(countResult[0]?.count ?? 0),
page,
limit,
diff --git a/apps/api/src/modules/payment/stripe-financial-state.spec.ts b/apps/api/src/modules/payment/stripe-financial-state.spec.ts
index c6d8d236..6115f4a6 100644
--- a/apps/api/src/modules/payment/stripe-financial-state.spec.ts
+++ b/apps/api/src/modules/payment/stripe-financial-state.spec.ts
@@ -1,7 +1,118 @@
-import { classifyHaipMetadata } from './stripe-financial-state';
+import { ConflictException } from '@nestjs/common';
+import {
+ classifyHaipMetadata,
+ decidePaymentIntentTransition,
+ decideRefundTransition,
+ paymentIntentCorrelation,
+ refundCorrelation,
+} from './stripe-financial-state';
-describe('Stripe financial metadata classification', () => {
- it('classifies HAIP-owned vs external PaymentIntent metadata', () => {
+describe('Stripe financial webhook state', () => {
+ it.each([
+ ['succeeded', 'captured'],
+ ['payment_failed', 'failed'],
+ ['canceled', 'voided'],
+ ['requires_action', 'failed'],
+ ] as const)('moves a pending PaymentIntent %s to %s', (event, expected) => {
+ expect(decidePaymentIntentTransition('pending', event, 'pending')).toEqual({
+ action: 'transition',
+ status: expected,
+ });
+ });
+
+ it.each(['failed', 'voided'] as const)(
+ 'never captures a terminal %s payment from a late success',
+ (current) => {
+ expect(decidePaymentIntentTransition(current, 'succeeded', 'pending')).toEqual({
+ action: 'unexpected',
+ status: current,
+ });
+ },
+ );
+
+ it('repairs a replay of the same terminal PaymentIntent result', () => {
+ expect(decidePaymentIntentTransition('captured', 'succeeded', 'accepted')).toEqual({
+ action: 'repair',
+ status: 'captured',
+ });
+ expect(decidePaymentIntentTransition('failed', 'payment_failed', 'pending')).toEqual({
+ action: 'repair',
+ status: 'failed',
+ });
+ });
+
+ it('blocks capture finalization once the booking request is denied', () => {
+ expect(() => decidePaymentIntentTransition('pending', 'succeeded', 'denied'))
+ .toThrow(ConflictException);
+ });
+
+ it.each([
+ ['succeeded', 'completed'],
+ ['failed', 'failed'],
+ ['canceled', 'failed'],
+ ['pending', 'pending'],
+ ['requires_action', 'pending'],
+ ] as const)('maps a pending refund %s monotonically to %s', (provider, expected) => {
+ expect(decideRefundTransition('pending', provider)).toEqual({
+ action: provider === 'pending' || provider === 'requires_action'
+ ? 'record_pending'
+ : 'transition',
+ status: expected,
+ });
+ });
+
+ it('does not regress completed or failed refund claims on out-of-order events', () => {
+ expect(decideRefundTransition('completed', 'failed')).toEqual({
+ action: 'unexpected',
+ status: 'completed',
+ });
+ expect(decideRefundTransition('failed', 'succeeded')).toEqual({
+ action: 'unexpected',
+ status: 'failed',
+ });
+ expect(decideRefundTransition('completed', 'succeeded')).toEqual({
+ action: 'repair',
+ status: 'completed',
+ });
+ });
+
+ it('requires exact claim-scoped Stripe refund metadata', () => {
+ expect(refundCorrelation({
+ haip_claim_id: 'claim-2',
+ haip_property_id: 'property-1',
+ haip_booking_request_id: 'request-1',
+ haip_payment_id: 'payment-1',
+ })).toEqual({
+ claimId: 'claim-2',
+ propertyId: 'property-1',
+ bookingRequestId: 'request-1',
+ paymentId: 'payment-1',
+ });
+ expect(() => refundCorrelation({ haip_claim_id: 'claim-2' }))
+ .toThrow(/correlation metadata/i);
+ });
+
+ it('classifies metadata by HAIP ownership before correlation parsing', () => {
+ expect(classifyHaipMetadata({}, paymentIntentCorrelation)).toEqual({ ownership: 'external' });
+ expect(classifyHaipMetadata({ unrelated: 'value' }, paymentIntentCorrelation))
+ .toEqual({ ownership: 'external' });
+ expect(classifyHaipMetadata({ haip_payment_id: 'payment-1' }, paymentIntentCorrelation))
+ .toMatchObject({ ownership: 'owned-malformed' });
+ expect(classifyHaipMetadata({
+ haip_payment_id: 'aaaaaaaa-0000-4000-a000-000000000001',
+ haip_property_id: 'bbbbbbbb-0000-4000-a000-000000000001',
+ haip_booking_request_id: 'cccccccc-0000-4000-a000-000000000001',
+ }, paymentIntentCorrelation)).toEqual({
+ ownership: 'owned-valid',
+ correlation: {
+ paymentId: 'aaaaaaaa-0000-4000-a000-000000000001',
+ propertyId: 'bbbbbbbb-0000-4000-a000-000000000001',
+ bookingRequestId: 'cccccccc-0000-4000-a000-000000000001',
+ },
+ });
+ });
+
+ it('classifies HAIP-owned vs external PaymentIntent metadata (core one-arg form)', () => {
expect(classifyHaipMetadata({})).toBe('external');
expect(classifyHaipMetadata({ unrelated: 'value' })).toBe('external');
expect(classifyHaipMetadata({ haip_payment_id: 'payment-1' })).toBe('owned-valid');
diff --git a/apps/api/src/modules/payment/stripe-financial-state.ts b/apps/api/src/modules/payment/stripe-financial-state.ts
index b95c2b9e..d2e92078 100644
--- a/apps/api/src/modules/payment/stripe-financial-state.ts
+++ b/apps/api/src/modules/payment/stripe-financial-state.ts
@@ -1,22 +1,21 @@
-export type HaipMetadataClassification = 'external' | 'owned-valid' | 'owned-malformed';
-
-export function hasHaipFinancialMetadata(
- metadata: Record | null | undefined,
-): boolean {
- return Object.keys(metadata ?? {}).some((key) => key.startsWith('haip_'));
-}
-
/**
- * Classifies PaymentIntent metadata for intents that remain unmatched after the
- * legacy-compatible gateway transaction lookup. Separates Stripe-account noise
- * from HAIP-owned traffic; event-specific correlation parsers remain responsible
- * for exact required fields.
+ * Canonical definition lives in @telivityhaip/shared — this logic is shared
+ * between core's `resolvePaymentForIntent` (stripe-webhook.controller.ts) and
+ * @telivityhaip/booking-requests' Stripe handler, so it cannot live only in
+ * apps/api without the package importing apps/api.
*/
-export function classifyHaipMetadata(
- metadata: Record | null | undefined,
-): HaipMetadataClassification {
- if (!hasHaipFinancialMetadata(metadata)) return 'external';
- return Object.entries(metadata ?? {}).some(([key, value]) => key.startsWith('haip_') && !value)
- ? 'owned-malformed'
- : 'owned-valid';
-}
+export {
+ type HaipMetadataClassification,
+ type HaipMetadataOwnership,
+ type PaymentIntentCorrelation,
+ type PaymentIntentEvent,
+ type PaymentIntentLedgerStatus,
+ type RefundCorrelation,
+ type RefundProviderStatus,
+ classifyHaipMetadata,
+ decidePaymentIntentTransition,
+ decideRefundTransition,
+ hasHaipFinancialMetadata,
+ paymentIntentCorrelation,
+ refundCorrelation,
+} from '@telivityhaip/shared';
diff --git a/apps/api/src/modules/payment/stripe-gateway.spec.ts b/apps/api/src/modules/payment/stripe-gateway.spec.ts
index d7d0a4db..c5f556c9 100644
--- a/apps/api/src/modules/payment/stripe-gateway.spec.ts
+++ b/apps/api/src/modules/payment/stripe-gateway.spec.ts
@@ -190,11 +190,69 @@ describe('StripeGateway', () => {
expect(result.success).toBe(true);
expect(result.transactionId).toBe('re_test_123');
+ expect(result.providerStatus).toBe('succeeded');
expect(stripeInstance.refunds.create).toHaveBeenCalledWith({
payment_intent: 'pi_test_123',
}, undefined);
});
+ it.each([
+ ['pending', 'pending'],
+ ['requires_action', 'requires_action'],
+ ['failed', 'failed'],
+ ['canceled', 'canceled'],
+ ['provider_specific_future_status', 'unknown'],
+ ] as const)(
+ 'reports a %s refund as %s without treating it as completed',
+ async (status, expectedStatus) => {
+ stripeInstance.refunds.create.mockResolvedValue({
+ id: `re_${status}`,
+ status,
+ failure_reason: status === 'failed' ? 'lost_or_stolen_card' : null,
+ });
+
+ const result = await gateway.refund('pi_test_123', 25, {
+ currencyCode: 'USD',
+ idempotencyKey: 'refund:claim-1',
+ });
+
+ expect(result).toEqual(expect.objectContaining({
+ success: false,
+ transactionId: `re_${status}`,
+ providerStatus: expectedStatus,
+ }));
+ },
+ );
+
+ it('attaches exact durable claim correlation metadata and stable idempotency', async () => {
+ stripeInstance.refunds.create.mockResolvedValue({
+ id: 're_correlated',
+ status: 'succeeded',
+ });
+
+ await gateway.refund('pi_test_123', 25, {
+ currencyCode: 'USD',
+ idempotencyKey: 'booking-request-refund:claim-uuid',
+ metadata: {
+ claimId: 'claim-uuid',
+ propertyId: 'property-uuid',
+ bookingRequestId: 'request-uuid',
+ paymentId: 'payment-uuid',
+ },
+ });
+
+ expect(stripeInstance.refunds.create).toHaveBeenCalledWith({
+ payment_intent: 'pi_test_123',
+ amount: 2500,
+ metadata: {
+ haip_claim_id: 'claim-uuid',
+ haip_property_id: 'property-uuid',
+ haip_booking_request_id: 'request-uuid',
+ haip_payment_id: 'payment-uuid',
+ },
+ }, { idempotencyKey: 'booking-request-refund:claim-uuid' });
+ });
+
it('should create a partial refund with amount in cents', async () => {
stripeInstance.refunds.create.mockResolvedValue({
id: 're_test_456',
@@ -209,15 +267,50 @@ describe('StripeGateway', () => {
}, undefined);
});
+ it('uses the ISO currency exponent for a JPY partial refund', async () => {
+ stripeInstance.refunds.create.mockResolvedValue({
+ id: 're_jpy',
+ status: 'succeeded',
+ });
+
+ await gateway.refund('pi_jpy', 51, { currencyCode: 'JPY' });
+
+ expect(stripeInstance.refunds.create).toHaveBeenCalledWith({
+ payment_intent: 'pi_jpy',
+ amount: 51,
+ }, undefined);
+ });
+
+ it('rejects a currency beyond ledger precision before calling Stripe', async () => {
+ const result = await gateway.refund('pi_bhd', 1, { currencyCode: 'BHD' });
+
+ expect(result.success).toBe(false);
+ expect(result.errorMessage).toMatch(/ledger.*precision/i);
+ expect(stripeInstance.refunds.create).not.toHaveBeenCalled();
+ });
+
it('should handle refund failure', async () => {
- stripeInstance.refunds.create.mockRejectedValue(
- new Error('Charge has already been refunded'),
- );
+ stripeInstance.refunds.create.mockRejectedValue({
+ type: 'StripeInvalidRequestError',
+ message: 'Charge has already been refunded',
+ });
const result = await gateway.refund('pi_test_123');
expect(result.success).toBe(false);
expect(result.errorMessage).toContain('already been refunded');
});
+
+ it('propagates an unknown transport result for durable same-key retry', async () => {
+ stripeInstance.refunds.create.mockRejectedValue(
+ new Error('connection reset after refund submission'),
+ );
+
+ await expect(gateway.refund(
+ 'pi_test_123',
+ 50,
+ { idempotencyKey: 'refund-same-key', currencyCode: 'USD' },
+ )).rejects.toThrow(/connection reset/i);
+ });
});
});
diff --git a/apps/api/src/modules/payment/stripe-gateway.ts b/apps/api/src/modules/payment/stripe-gateway.ts
index ca8f951f..be8f599b 100644
--- a/apps/api/src/modules/payment/stripe-gateway.ts
+++ b/apps/api/src/modules/payment/stripe-gateway.ts
@@ -1,12 +1,15 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
+import Decimal from 'decimal.js';
import type {
PaymentGateway,
PaymentGatewayCallOptions,
PaymentGatewayResult,
} from './interfaces/payment-gateway.interface';
+class StripeLedgerValidationError extends Error {}
+
/**
* Stripe implementation of PaymentGateway.
*
@@ -51,6 +54,37 @@ export class StripeGateway implements PaymentGateway {
return undefined;
}
+ private toLedgerMinorUnits(amount: number, currencyCode: string): number {
+ const normalized = currencyCode.trim().toUpperCase();
+ const exponent = new Intl.NumberFormat('en', {
+ style: 'currency',
+ currency: normalized,
+ }).resolvedOptions().maximumFractionDigits;
+ if (exponent == null) {
+ throw new StripeLedgerValidationError(
+ `Unable to resolve minor-unit exponent for '${normalized}'`,
+ );
+ }
+ if (exponent > 2) {
+ throw new StripeLedgerValidationError(
+ `${normalized} minor-unit exponent ${exponent} exceeds ledger storage precision`,
+ );
+ }
+ const minorUnits = new Decimal(amount).mul(new Decimal(10).pow(exponent));
+ if (!minorUnits.isInteger()) {
+ throw new StripeLedgerValidationError(
+ `Amount '${amount}' ${normalized} has fractional minor units`,
+ );
+ }
+ const value = minorUnits.toNumber();
+ if (!Number.isSafeInteger(value)) {
+ throw new StripeLedgerValidationError(
+ `Amount '${amount}' ${normalized} exceeds safe integer minor units`,
+ );
+ }
+ return value;
+ }
+
async authorize(
token: string,
amount: number,
@@ -159,21 +193,65 @@ export class StripeGateway implements PaymentGateway {
payment_intent: transactionId,
};
if (amount !== undefined) {
- params.amount = Math.round(amount * 100);
+ params.amount = this.toLedgerMinorUnits(amount, options?.currencyCode ?? 'USD');
+ }
+ if (options?.metadata) {
+ params.metadata = {
+ haip_claim_id: options.metadata.claimId,
+ haip_property_id: options.metadata.propertyId,
+ haip_booking_request_id: options.metadata.bookingRequestId,
+ haip_payment_id: options.metadata.paymentId,
+ };
}
const refund = await this.stripe.refunds.create(params, this.requestOptions(options));
this.logger.log(`Refund created: ${refund.id} for ${transactionId}`);
- return { success: true, transactionId: refund.id };
- } catch (err: any) {
- this.logger.error(`Stripe refund failed: ${err.message}`, err.stack);
+ const providerStatus = this.refundProviderStatus(refund.status);
return {
- success: false,
- transactionId: transactionId,
- errorMessage: err.message ?? 'Refund failed',
+ success: providerStatus === 'succeeded',
+ transactionId: refund.id,
+ providerStatus,
+ ...((providerStatus === 'failed' || providerStatus === 'canceled') && {
+ errorMessage: refund.failure_reason
+ ? `Stripe refund ${providerStatus}: ${refund.failure_reason}`
+ : `Stripe refund ${providerStatus}`,
+ }),
};
+ } catch (err: any) {
+ this.logger.error(`Stripe refund failed: ${err.message}`, err.stack);
+ if (err instanceof StripeLedgerValidationError || this.isExplicitProviderRejection(err)) {
+ return {
+ success: false,
+ transactionId: '',
+ providerStatus: 'failed',
+ errorMessage: err.message ?? 'Refund failed',
+ };
+ }
+ throw err;
+ }
+ }
+
+ private isExplicitProviderRejection(error: unknown): boolean {
+ if (typeof error !== 'object' || error === null || !('type' in error)) return false;
+ return error.type === 'StripeInvalidRequestError'
+ || error.type === 'StripeCardError'
+ || error.type === 'StripeAuthenticationError';
+ }
+
+ private refundProviderStatus(
+ status: string | null | undefined,
+ ): NonNullable {
+ switch (status) {
+ case 'succeeded':
+ case 'pending':
+ case 'requires_action':
+ case 'failed':
+ case 'canceled':
+ return status;
+ default:
+ return 'unknown';
}
}
}
diff --git a/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.spec.ts b/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.spec.ts
new file mode 100644
index 00000000..a1bccc81
--- /dev/null
+++ b/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.spec.ts
@@ -0,0 +1,491 @@
+import type { ConfigService } from '@nestjs/config';
+import { StripeSavedPaymentMethodGateway } from './stripe-saved-payment-method.gateway';
+
+vi.mock('stripe', () => ({
+ default: vi.fn().mockImplementation(() => ({
+ customers: {
+ create: vi.fn(),
+ },
+ setupIntents: {
+ create: vi.fn(),
+ retrieve: vi.fn(),
+ },
+ paymentMethods: {
+ retrieve: vi.fn(),
+ },
+ paymentIntents: {
+ create: vi.fn(),
+ },
+ })),
+}));
+
+function config(secretKey = 'sk_test_saved_method'): ConfigService {
+ return {
+ get: vi.fn((key: string) => key === 'STRIPE_SECRET_KEY' ? secretKey : undefined),
+ } as unknown as ConfigService;
+}
+
+describe('StripeSavedPaymentMethodGateway', () => {
+ const provenance = {
+ propertyId: 'aaaaaaaa-0000-4000-a000-000000000001',
+ applicationId: 'submission-attempt-1',
+ };
+
+ let gateway: StripeSavedPaymentMethodGateway;
+ let stripe: {
+ customers: { create: ReturnType };
+ setupIntents: {
+ create: ReturnType;
+ retrieve: ReturnType;
+ };
+ paymentMethods: { retrieve: ReturnType };
+ paymentIntents: { create: ReturnType };
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ gateway = new StripeSavedPaymentMethodGateway(config());
+ stripe = (gateway as unknown as { stripe: typeof stripe }).stripe;
+ });
+
+ it('requires a Stripe secret key', () => {
+ expect(() => new StripeSavedPaymentMethodGateway(config(''))).toThrow(
+ /STRIPE_SECRET_KEY is required/,
+ );
+ });
+
+ it('creates an off-session card setup for a new customer idempotently', async () => {
+ stripe.customers.create.mockResolvedValue({ id: 'cus_trusted' });
+ stripe.setupIntents.create.mockResolvedValue({
+ id: 'seti_trusted',
+ client_secret: 'seti_secret_safe_for_guest',
+ });
+
+ await expect(
+ gateway.createSetup(
+ 'guest@example.com',
+ 'request-card:req_123',
+ provenance,
+ ),
+ ).resolves.toEqual({
+ setupIntentId: 'seti_trusted',
+ clientSecret: 'seti_secret_safe_for_guest',
+ customerId: 'cus_trusted',
+ clientMode: 'stripe',
+ });
+ expect(stripe.customers.create).toHaveBeenCalledWith(
+ { email: 'guest@example.com' },
+ {
+ idempotencyKey:
+ 'saved-method:554b9d6897beb16e36c4e97dae44a87a176537902c77313b11d229abc0ebeda1:customer',
+ },
+ );
+ expect(stripe.setupIntents.create).toHaveBeenCalledWith(
+ {
+ customer: 'cus_trusted',
+ usage: 'off_session',
+ payment_method_types: ['card'],
+ metadata: {
+ haip_property_id: provenance.propertyId,
+ haip_application_hash:
+ 'cf18a22e39cd5bba19be060f31c6a9e68094cefbaf2c4a23c5738bf78c687a3a',
+ },
+ },
+ {
+ idempotencyKey:
+ 'saved-method:554b9d6897beb16e36c4e97dae44a87a176537902c77313b11d229abc0ebeda1:setup-intent',
+ },
+ );
+ });
+
+ it('derives stable bounded Stripe idempotency keys from the full application identity', async () => {
+ stripe.customers.create.mockResolvedValue({ id: 'cus_trusted' });
+ stripe.setupIntents.create.mockResolvedValue({
+ id: 'seti_trusted',
+ client_secret: 'seti_secret_safe_for_guest',
+ });
+ const fullIdentity = `booking-request:${provenance.propertyId}:${'a'.repeat(200)}`;
+
+ await gateway.createSetup('guest@example.com', fullIdentity, provenance);
+ await gateway.createSetup('guest@example.com', fullIdentity, provenance);
+
+ const customerKeys = stripe.customers.create.mock.calls.map(
+ (call) => (call[1] as { idempotencyKey: string }).idempotencyKey,
+ );
+ const setupKeys = stripe.setupIntents.create.mock.calls.map(
+ (call) => (call[1] as { idempotencyKey: string }).idempotencyKey,
+ );
+ expect(customerKeys[0]).toBe(customerKeys[1]);
+ expect(setupKeys[0]).toBe(setupKeys[1]);
+ expect(customerKeys[0]).not.toBe(setupKeys[0]);
+ expect(customerKeys[0]!.length).toBeLessThanOrEqual(255);
+ expect(setupKeys[0]!.length).toBeLessThanOrEqual(255);
+ expect(customerKeys[0]).not.toContain('a'.repeat(200));
+ expect(setupKeys[0]).not.toContain('a'.repeat(200));
+ });
+
+ it('rejects setup resolution unless Stripe reports success', async () => {
+ stripe.setupIntents.retrieve.mockResolvedValue({
+ id: 'seti_unconfirmed',
+ status: 'requires_payment_method',
+ customer: 'cus_untrusted',
+ payment_method: 'pm_untrusted',
+ });
+
+ await expect(gateway.resolveSetup('seti_unconfirmed', provenance)).rejects.toThrow(
+ /has not succeeded/,
+ );
+ expect(stripe.paymentMethods.retrieve).not.toHaveBeenCalled();
+ });
+
+ it('returns only trusted Stripe IDs and safe card display metadata', async () => {
+ stripe.setupIntents.retrieve.mockResolvedValue({
+ id: 'seti_trusted',
+ status: 'succeeded',
+ customer: 'cus_trusted',
+ payment_method: 'pm_trusted',
+ metadata: {
+ haip_property_id: provenance.propertyId,
+ haip_application_hash:
+ 'cf18a22e39cd5bba19be060f31c6a9e68094cefbaf2c4a23c5738bf78c687a3a',
+ },
+ });
+ stripe.paymentMethods.retrieve.mockResolvedValue({
+ id: 'pm_trusted',
+ type: 'card',
+ customer: { id: 'cus_trusted' },
+ card: {
+ brand: 'visa',
+ last4: '4242',
+ exp_month: 12,
+ exp_year: 2035,
+ fingerprint: 'server-only-fingerprint',
+ },
+ });
+
+ const result = await gateway.resolveSetup('seti_trusted', provenance);
+
+ expect(stripe.setupIntents.retrieve).toHaveBeenCalledWith('seti_trusted');
+ expect(stripe.paymentMethods.retrieve).toHaveBeenCalledWith('pm_trusted');
+ expect(result).toEqual({
+ setupIntentId: 'seti_trusted',
+ customerId: 'cus_trusted',
+ paymentMethodId: 'pm_trusted',
+ cardLastFour: '4242',
+ cardBrand: 'visa',
+ });
+ expect(result).not.toHaveProperty('fingerprint');
+ expect(result).not.toHaveProperty('clientSecret');
+ });
+
+ it('rejects a succeeded setup that does not resolve to a card', async () => {
+ stripe.setupIntents.retrieve.mockResolvedValue({
+ id: 'seti_bank',
+ status: 'succeeded',
+ customer: 'cus_trusted',
+ payment_method: 'pm_bank',
+ metadata: {
+ haip_property_id: provenance.propertyId,
+ haip_application_hash:
+ 'cf18a22e39cd5bba19be060f31c6a9e68094cefbaf2c4a23c5738bf78c687a3a',
+ },
+ });
+ stripe.paymentMethods.retrieve.mockResolvedValue({
+ id: 'pm_bank',
+ type: 'us_bank_account',
+ customer: 'cus_trusted',
+ card: null,
+ });
+
+ await expect(
+ gateway.resolveSetup('seti_bank', provenance),
+ ).rejects.toThrow(/card payment method/);
+ });
+
+ it('rejects a successful SetupIntent issued for another property or application', async () => {
+ stripe.setupIntents.retrieve.mockResolvedValue({
+ id: 'seti_wrong_scope',
+ status: 'succeeded',
+ customer: 'cus_trusted',
+ payment_method: 'pm_trusted',
+ metadata: {
+ haip_property_id: provenance.propertyId,
+ haip_application_hash:
+ 'cf18a22e39cd5bba19be060f31c6a9e68094cefbaf2c4a23c5738bf78c687a3a',
+ },
+ });
+
+ await expect(gateway.resolveSetup('seti_wrong_scope', {
+ ...provenance,
+ propertyId: 'ffffffff-0000-4000-a000-000000000001',
+ })).rejects.toThrow(/provenance/i);
+ await expect(gateway.resolveSetup('seti_wrong_scope', {
+ ...provenance,
+ applicationId: 'submission-attempt-2',
+ })).rejects.toThrow(/provenance/i);
+ expect(stripe.paymentMethods.retrieve).not.toHaveBeenCalled();
+ });
+
+ it('rejects a PaymentMethod attached to a different Stripe customer', async () => {
+ stripe.setupIntents.retrieve.mockResolvedValue({
+ id: 'seti_mismatch',
+ status: 'succeeded',
+ customer: 'cus_setup_owner',
+ payment_method: 'pm_mismatched',
+ metadata: {
+ haip_property_id: provenance.propertyId,
+ haip_application_hash:
+ 'cf18a22e39cd5bba19be060f31c6a9e68094cefbaf2c4a23c5738bf78c687a3a',
+ },
+ });
+ stripe.paymentMethods.retrieve.mockResolvedValue({
+ id: 'pm_mismatched',
+ type: 'card',
+ customer: { id: 'cus_different_owner' },
+ card: {
+ brand: 'visa',
+ last4: '4242',
+ },
+ });
+
+ await expect(gateway.resolveSetup('seti_mismatch', provenance)).rejects.toThrow(
+ /PaymentMethod.*does not belong.*cus_setup_owner/,
+ );
+ });
+
+ it('confirms an off-session PaymentIntent with automatic capture and idempotency', async () => {
+ stripe.paymentIntents.create.mockResolvedValue({
+ id: 'pi_captured',
+ status: 'succeeded',
+ });
+
+ await expect(gateway.charge({
+ customerId: 'cus_trusted',
+ paymentMethodId: 'pm_trusted',
+ paymentId: 'cccccccc-0000-4000-a000-000000000001',
+ propertyId: 'aaaaaaaa-0000-4000-a000-000000000001',
+ bookingRequestId: 'bbbbbbbb-0000-4000-a000-000000000001',
+ amount: '123.45',
+ currencyCode: 'EUR',
+ idempotencyKey: 'request-charge:payment_123',
+ })).resolves.toEqual({
+ success: true,
+ transactionId: 'pi_captured',
+ requiresAction: false,
+ });
+ expect(stripe.paymentIntents.create).toHaveBeenCalledWith(
+ {
+ amount: 12345,
+ currency: 'eur',
+ customer: 'cus_trusted',
+ payment_method: 'pm_trusted',
+ metadata: {
+ haip_payment_id: 'cccccccc-0000-4000-a000-000000000001',
+ haip_property_id: 'aaaaaaaa-0000-4000-a000-000000000001',
+ haip_booking_request_id: 'bbbbbbbb-0000-4000-a000-000000000001',
+ },
+ confirm: true,
+ off_session: true,
+ capture_method: 'automatic',
+ automatic_payment_methods: {
+ enabled: true,
+ allow_redirects: 'never',
+ },
+ },
+ { idempotencyKey: 'request-charge:payment_123' },
+ );
+ });
+
+ it('returns a typed indeterminate result with the PaymentIntent identity while processing', async () => {
+ stripe.paymentIntents.create.mockResolvedValue({
+ id: 'pi_processing',
+ status: 'processing',
+ });
+
+ await expect(gateway.charge({
+ customerId: 'cus_trusted',
+ paymentMethodId: 'pm_trusted',
+ amount: '25.00',
+ currencyCode: 'USD',
+ idempotencyKey: 'request-charge:processing',
+ })).resolves.toEqual({
+ success: false,
+ transactionId: 'pi_processing',
+ requiresAction: false,
+ indeterminate: true,
+ providerStatus: 'processing',
+ errorMessage: "Stripe PaymentIntent 'pi_processing' result is still processing",
+ });
+ });
+
+ it.each([
+ { currencyCode: 'JPY', amount: '123', expectedMinorUnits: 123 },
+ ])(
+ 'uses the ISO-4217 exponent for $currencyCode without losing Decimal exactness',
+ async ({ currencyCode, amount, expectedMinorUnits }) => {
+ stripe.paymentIntents.create.mockResolvedValue({
+ id: `pi_${currencyCode.toLowerCase()}`,
+ status: 'succeeded',
+ });
+
+ const result = await gateway.charge({
+ customerId: 'cus_trusted',
+ paymentMethodId: 'pm_trusted',
+ amount,
+ currencyCode,
+ idempotencyKey: `request-charge:${currencyCode}`,
+ });
+
+ expect(result.success).toBe(true);
+ expect(stripe.paymentIntents.create).toHaveBeenCalledWith(
+ expect.objectContaining({ amount: expectedMinorUnits }),
+ { idempotencyKey: `request-charge:${currencyCode}` },
+ );
+ },
+ );
+
+ it.each([
+ { currencyCode: 'JPY', amount: '1.5' },
+ { currencyCode: 'USD', amount: '1.001' },
+ ])(
+ 'rejects $amount $currencyCode instead of rounding a fractional minor unit',
+ async ({ currencyCode, amount }) => {
+ const result = await gateway.charge({
+ customerId: 'cus_trusted',
+ paymentMethodId: 'pm_trusted',
+ amount,
+ currencyCode,
+ idempotencyKey: `request-charge:fractional-${currencyCode}`,
+ });
+
+ expect(result).toEqual({
+ success: false,
+ transactionId: '',
+ requiresAction: false,
+ errorMessage: expect.stringMatching(/fractional minor units/),
+ });
+ expect(stripe.paymentIntents.create).not.toHaveBeenCalled();
+ },
+ );
+
+ it('rejects a scale-three currency before creating a PaymentIntent', async () => {
+ const result = await gateway.charge({
+ customerId: 'cus_trusted',
+ paymentMethodId: 'pm_trusted',
+ amount: '1.234',
+ currencyCode: 'BHD',
+ idempotencyKey: 'request-charge:unsupported-bhd',
+ });
+
+ expect(result).toEqual({
+ success: false,
+ transactionId: '',
+ requiresAction: false,
+ errorMessage: expect.stringMatching(/exceeds ledger storage precision/i),
+ });
+ expect(stripe.paymentIntents.create).not.toHaveBeenCalled();
+ });
+
+ it('rejects an unknown currency code before calling Stripe', async () => {
+ const result = await gateway.charge({
+ customerId: 'cus_trusted',
+ paymentMethodId: 'pm_trusted',
+ amount: '10.00',
+ currencyCode: 'ZZZ',
+ idempotencyKey: 'request-charge:unknown-currency',
+ });
+
+ expect(result).toEqual({
+ success: false,
+ transactionId: '',
+ requiresAction: false,
+ errorMessage: "Unsupported ISO-4217 currency code 'ZZZ'",
+ });
+ expect(stripe.paymentIntents.create).not.toHaveBeenCalled();
+ });
+
+ it('maps additional authentication to a failed charge with no recovery secret', async () => {
+ stripe.paymentIntents.create.mockResolvedValue({
+ id: 'pi_requires_action',
+ status: 'requires_action',
+ client_secret: 'must_not_leave_gateway',
+ });
+
+ const result = await gateway.charge({
+ customerId: 'cus_trusted',
+ paymentMethodId: 'pm_trusted',
+ amount: '40.00',
+ currencyCode: 'USD',
+ idempotencyKey: 'request-charge:payment_action',
+ });
+
+ expect(result).toEqual({
+ success: false,
+ transactionId: 'pi_requires_action',
+ requiresAction: true,
+ errorMessage: 'Payment requires additional authentication',
+ });
+ expect(result).not.toHaveProperty('clientSecret');
+ expect(result).not.toHaveProperty('authenticationUrl');
+ });
+
+ it('maps Stripe off-session authentication errors to the same terminal failure', async () => {
+ stripe.paymentIntents.create.mockRejectedValue({
+ message: 'This payment requires authentication',
+ payment_intent: {
+ id: 'pi_error_requires_action',
+ status: 'requires_action',
+ client_secret: 'must_not_leave_gateway',
+ },
+ });
+
+ await expect(gateway.charge({
+ customerId: 'cus_trusted',
+ paymentMethodId: 'pm_trusted',
+ amount: '40.00',
+ currencyCode: 'USD',
+ idempotencyKey: 'request-charge:payment_error_action',
+ })).resolves.toEqual({
+ success: false,
+ transactionId: 'pi_error_requires_action',
+ requiresAction: true,
+ errorMessage: 'Payment requires additional authentication',
+ });
+ });
+
+ it('propagates a transport error so the durable payment claim remains retryable', async () => {
+ stripe.paymentIntents.create.mockRejectedValue(new Error('connection reset after write'));
+
+ await expect(gateway.charge({
+ customerId: 'cus_trusted',
+ paymentMethodId: 'pm_trusted',
+ amount: '40.00',
+ currencyCode: 'USD',
+ idempotencyKey: 'request-charge:transport-error',
+ })).rejects.toThrow(/connection reset/i);
+ });
+
+ it('maps an explicit Stripe card decline to a terminal failure', async () => {
+ stripe.paymentIntents.create.mockRejectedValue({
+ type: 'StripeCardError',
+ message: 'Your card was declined',
+ payment_intent: {
+ id: 'pi_declined',
+ status: 'requires_payment_method',
+ },
+ });
+
+ await expect(gateway.charge({
+ customerId: 'cus_trusted',
+ paymentMethodId: 'pm_trusted',
+ amount: '40.00',
+ currencyCode: 'USD',
+ idempotencyKey: 'request-charge:declined',
+ })).resolves.toEqual({
+ success: false,
+ transactionId: 'pi_declined',
+ requiresAction: false,
+ errorMessage: 'Your card was declined',
+ });
+ });
+});
diff --git a/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts b/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts
new file mode 100644
index 00000000..184e2a84
--- /dev/null
+++ b/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts
@@ -0,0 +1,293 @@
+import { Injectable } from '@nestjs/common';
+import type { ConfigService } from '@nestjs/config';
+import { createHash } from 'node:crypto';
+import Decimal from 'decimal.js';
+import Stripe from 'stripe';
+import type {
+ SavedPaymentMethod,
+ SavedPaymentMethodChargeInput,
+ SavedPaymentMethodChargeResult,
+ SavedPaymentMethodGateway,
+ SavedPaymentMethodProvenance,
+} from './interfaces/saved-payment-method-gateway.interface';
+
+const PROPERTY_METADATA_KEY = 'haip_property_id';
+const APPLICATION_METADATA_KEY = 'haip_application_hash';
+
+class SavedPaymentMethodValidationError extends Error {}
+
+@Injectable()
+export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGateway {
+ private readonly stripe: Stripe;
+
+ constructor(configService: ConfigService) {
+ const secretKey = configService.get('STRIPE_SECRET_KEY');
+ if (!secretKey) {
+ throw new Error(
+ 'STRIPE_SECRET_KEY is required for saved Stripe payment methods. ' +
+ 'Set STRIPE_MODE=mock for development without Stripe keys.',
+ );
+ }
+
+ this.stripe = new Stripe(secretKey, {
+ apiVersion: '2025-03-31.basil',
+ typescript: true,
+ });
+ }
+
+ async createSetup(
+ email: string,
+ idempotencyKey: string,
+ provenance: SavedPaymentMethodProvenance,
+ ): Promise<{
+ setupIntentId: string;
+ clientSecret: string;
+ customerId: string;
+ clientMode: 'stripe';
+ }> {
+ const idempotencyHash = this.applicationHash(idempotencyKey);
+ const customer = await this.stripe.customers.create(
+ { email },
+ { idempotencyKey: `saved-method:${idempotencyHash}:customer` },
+ );
+ const setupIntent = await this.stripe.setupIntents.create(
+ {
+ customer: customer.id,
+ usage: 'off_session',
+ payment_method_types: ['card'],
+ metadata: {
+ [PROPERTY_METADATA_KEY]: provenance.propertyId,
+ [APPLICATION_METADATA_KEY]: this.applicationHash(provenance.applicationId),
+ },
+ },
+ { idempotencyKey: `saved-method:${idempotencyHash}:setup-intent` },
+ );
+
+ if (!setupIntent.client_secret) {
+ throw new Error(`Stripe SetupIntent '${setupIntent.id}' has no client secret`);
+ }
+
+ return {
+ setupIntentId: setupIntent.id,
+ clientSecret: setupIntent.client_secret,
+ customerId: customer.id,
+ clientMode: 'stripe',
+ };
+ }
+
+ async resolveSetup(
+ setupIntentId: string,
+ expectedProvenance: SavedPaymentMethodProvenance,
+ ): Promise {
+ const setupIntent = await this.stripe.setupIntents.retrieve(setupIntentId);
+ if (setupIntent.status !== 'succeeded') {
+ throw new Error(`Stripe SetupIntent '${setupIntentId}' has not succeeded`);
+ }
+ if (
+ setupIntent.metadata?.[PROPERTY_METADATA_KEY] !== expectedProvenance.propertyId
+ || setupIntent.metadata?.[APPLICATION_METADATA_KEY]
+ !== this.applicationHash(expectedProvenance.applicationId)
+ ) {
+ throw new Error(`Stripe SetupIntent '${setupIntentId}' provenance does not match`);
+ }
+
+ const customerId = this.expandedId(setupIntent.customer);
+ const paymentMethodId = this.expandedId(setupIntent.payment_method);
+ if (!customerId || !paymentMethodId) {
+ throw new Error(`Stripe SetupIntent '${setupIntentId}' is missing saved payment references`);
+ }
+
+ const paymentMethod = await this.stripe.paymentMethods.retrieve(paymentMethodId);
+ const attachedCustomerId = this.expandedId(paymentMethod.customer);
+ if (attachedCustomerId !== customerId) {
+ throw new Error(
+ `Stripe PaymentMethod '${paymentMethod.id}' does not belong to ` +
+ `SetupIntent customer '${customerId}'`,
+ );
+ }
+ if (paymentMethod.type !== 'card' || !paymentMethod.card) {
+ throw new Error(`Stripe SetupIntent '${setupIntentId}' did not save a card payment method`);
+ }
+
+ return {
+ setupIntentId: setupIntent.id,
+ customerId,
+ paymentMethodId: paymentMethod.id,
+ cardLastFour: paymentMethod.card.last4,
+ cardBrand: paymentMethod.card.brand,
+ };
+ }
+
+ async charge(input: SavedPaymentMethodChargeInput): Promise {
+ try {
+ const currencyCode = this.normalizeCurrencyCode(input.currencyCode);
+ const paymentIntent = await this.stripe.paymentIntents.create(
+ {
+ amount: this.toMinorUnits(input.amount, currencyCode),
+ currency: currencyCode.toLowerCase(),
+ customer: input.customerId,
+ payment_method: input.paymentMethodId,
+ metadata: {
+ haip_payment_id: input.paymentId,
+ haip_property_id: input.propertyId,
+ haip_booking_request_id: input.bookingRequestId,
+ },
+ confirm: true,
+ off_session: true,
+ capture_method: 'automatic',
+ automatic_payment_methods: {
+ enabled: true,
+ allow_redirects: 'never',
+ },
+ },
+ { idempotencyKey: input.idempotencyKey },
+ );
+
+ return this.mapPaymentIntent(paymentIntent);
+ } catch (error: unknown) {
+ const stripePaymentIntent = this.paymentIntentFromError(error);
+ if (stripePaymentIntent?.status === 'requires_action') {
+ return this.requiresAction(stripePaymentIntent.id);
+ }
+ if (
+ error instanceof SavedPaymentMethodValidationError
+ || this.isExplicitDecline(error, stripePaymentIntent)
+ ) {
+ return {
+ success: false,
+ transactionId: stripePaymentIntent?.id ?? '',
+ requiresAction: false,
+ errorMessage: this.errorMessage(error),
+ };
+ }
+ throw error;
+ }
+ }
+
+ private expandedId(value: string | { id: string } | null): string | null {
+ return typeof value === 'string' ? value : value?.id ?? null;
+ }
+
+ private applicationHash(applicationId: string): string {
+ return createHash('sha256').update(applicationId).digest('hex');
+ }
+
+ private normalizeCurrencyCode(currencyCode: string): string {
+ const normalized = currencyCode.trim().toUpperCase();
+ const intlWithSupportedValues = Intl as typeof Intl & {
+ supportedValuesOf?: (key: 'currency') => string[];
+ };
+ if (
+ !intlWithSupportedValues.supportedValuesOf ||
+ !intlWithSupportedValues.supportedValuesOf('currency').includes(normalized)
+ ) {
+ throw new SavedPaymentMethodValidationError(
+ `Unsupported ISO-4217 currency code '${currencyCode}'`,
+ );
+ }
+ return normalized;
+ }
+
+ private toMinorUnits(amount: string, currencyCode: string): number {
+ const exponent = new Intl.NumberFormat('en', {
+ style: 'currency',
+ currency: currencyCode,
+ }).resolvedOptions().maximumFractionDigits;
+ if (exponent === undefined) {
+ throw new SavedPaymentMethodValidationError(
+ `Unable to resolve minor-unit exponent for '${currencyCode}'`,
+ );
+ }
+ if (exponent > 2) {
+ throw new SavedPaymentMethodValidationError(
+ `${currencyCode} minor-unit exponent ${exponent} exceeds ledger storage precision`,
+ );
+ }
+ const minorUnits = new Decimal(amount).mul(new Decimal(10).pow(exponent));
+ if (!minorUnits.isInteger()) {
+ throw new SavedPaymentMethodValidationError(
+ `Amount '${amount}' ${currencyCode} has fractional minor units`,
+ );
+ }
+ const value = minorUnits.toNumber();
+ if (!Number.isSafeInteger(value)) {
+ throw new SavedPaymentMethodValidationError(
+ `Amount '${amount}' ${currencyCode} exceeds the safe Stripe integer range`,
+ );
+ }
+ return value;
+ }
+
+ private mapPaymentIntent(paymentIntent: Stripe.PaymentIntent): SavedPaymentMethodChargeResult {
+ if (paymentIntent.status === 'succeeded') {
+ return {
+ success: true,
+ transactionId: paymentIntent.id,
+ requiresAction: false,
+ };
+ }
+ if (paymentIntent.status === 'requires_action') {
+ return this.requiresAction(paymentIntent.id);
+ }
+ if (paymentIntent.status === 'processing') {
+ return {
+ success: false,
+ transactionId: paymentIntent.id,
+ requiresAction: false,
+ indeterminate: true,
+ providerStatus: paymentIntent.status,
+ errorMessage: `Stripe PaymentIntent '${paymentIntent.id}' result is still processing`,
+ };
+ }
+ return {
+ success: false,
+ transactionId: paymentIntent.id,
+ requiresAction: false,
+ errorMessage: `Unexpected Stripe PaymentIntent status: ${paymentIntent.status}`,
+ };
+ }
+
+ private requiresAction(transactionId: string): SavedPaymentMethodChargeResult {
+ return {
+ success: false,
+ transactionId,
+ requiresAction: true,
+ errorMessage: 'Payment requires additional authentication',
+ };
+ }
+
+ private paymentIntentFromError(error: unknown): Stripe.PaymentIntent | undefined {
+ if (typeof error !== 'object' || error === null || !('payment_intent' in error)) {
+ return undefined;
+ }
+ const paymentIntent = error.payment_intent;
+ return typeof paymentIntent === 'object' && paymentIntent !== null && 'id' in paymentIntent
+ ? paymentIntent as Stripe.PaymentIntent
+ : undefined;
+ }
+
+ private isExplicitDecline(
+ error: unknown,
+ paymentIntent?: Stripe.PaymentIntent,
+ ): boolean {
+ const status = paymentIntent?.status;
+ if (status === 'requires_payment_method' || status === 'canceled') return true;
+ return typeof error === 'object'
+ && error !== null
+ && 'type' in error
+ && error.type === 'StripeCardError';
+ }
+
+ private errorMessage(error: unknown): string {
+ if (error instanceof Error) return error.message;
+ if (
+ typeof error === 'object'
+ && error !== null
+ && 'message' in error
+ && typeof error.message === 'string'
+ ) {
+ return error.message;
+ }
+ return 'Stripe charge failed';
+ }
+}
diff --git a/apps/api/src/modules/payment/stripe-webhook.controller.ts b/apps/api/src/modules/payment/stripe-webhook.controller.ts
index 9d8b2aec..f49dfae6 100644
--- a/apps/api/src/modules/payment/stripe-webhook.controller.ts
+++ b/apps/api/src/modules/payment/stripe-webhook.controller.ts
@@ -6,6 +6,7 @@ import {
Logger,
BadRequestException,
Inject,
+ Optional,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ApiTags, ApiOperation, ApiExcludeEndpoint } from '@nestjs/swagger';
@@ -17,6 +18,12 @@ import { DRIZZLE } from '../../database/database.module';
import { WebhookService } from '../webhook/webhook.service';
import { FolioService } from '../folio/folio.service';
import { sumRefundChildren } from './payment-ledger';
+import {
+ BOOKING_REQUEST_STRIPE_HANDLER,
+ paymentHasBookingRequestId,
+ type BookingRequestStripeHandler,
+ type BookingRequestStripePaymentRow,
+} from './booking-request-stripe-handler.interface';
import { classifyHaipMetadata } from './stripe-financial-state';
import Stripe from 'stripe';
@@ -44,6 +51,9 @@ export class StripeWebhookController {
private readonly webhookService: WebhookService,
private readonly folioService: FolioService,
private readonly configService: ConfigService,
+ @Optional()
+ @Inject(BOOKING_REQUEST_STRIPE_HANDLER)
+ private readonly bookingRequestStripeHandler?: BookingRequestStripeHandler,
) {
const secretKey = this.configService.get('STRIPE_SECRET_KEY');
this.webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET') ?? null;
@@ -110,6 +120,20 @@ export class StripeWebhookController {
await this.handlePaymentIntentCanceled(event.data.object as Stripe.PaymentIntent);
break;
+ case 'payment_intent.processing':
+ await this.handlePaymentIntentProcessing(event.data.object as Stripe.PaymentIntent);
+ break;
+
+ case 'payment_intent.requires_action':
+ await this.handlePaymentIntentRequiresAction(event.data.object as Stripe.PaymentIntent);
+ break;
+
+ case 'refund.created':
+ case 'refund.updated':
+ case 'refund.failed':
+ await this.handleRefundUpdated(event.data.object as Stripe.Refund);
+ break;
+
case 'charge.refunded':
await this.handleChargeRefunded(event.data.object as Stripe.Charge);
break;
@@ -130,6 +154,11 @@ export class StripeWebhookController {
const payment = await this.resolvePaymentForIntent(pi);
if (!payment) return;
+ if (this.shouldDelegateToBookingRequestHandler(payment)) {
+ await this.bookingRequestStripeHandler!.handlePaymentIntentSucceeded(pi, payment);
+ return;
+ }
+
if (payment.status === 'captured') {
this.logger.debug(`Payment ${payment.id} already captured, skipping`);
return;
@@ -141,7 +170,9 @@ export class StripeWebhookController {
.where(and(eq(payments.id, payment.id), eq(payments.propertyId, payment.propertyId)));
// Recalculate folio balance after payment state change
- await this.folioService.recalculateBalance(payment.folioId, payment.propertyId);
+ if (payment.folioId) {
+ await this.folioService.recalculateBalance(payment.folioId, payment.propertyId);
+ }
await this.webhookService.emit(
'payment.received',
@@ -158,6 +189,11 @@ export class StripeWebhookController {
const payment = await this.resolvePaymentForIntent(pi);
if (!payment) return;
+ if (this.shouldDelegateToBookingRequestHandler(payment)) {
+ await this.bookingRequestStripeHandler!.handlePaymentIntentFailed(pi, payment);
+ return;
+ }
+
if (payment.status === 'failed') return;
const errorMessage = pi.last_payment_error?.message ?? 'Payment failed';
@@ -168,7 +204,9 @@ export class StripeWebhookController {
.where(and(eq(payments.id, payment.id), eq(payments.propertyId, payment.propertyId)));
// Recalculate folio balance after payment state change
- await this.folioService.recalculateBalance(payment.folioId, payment.propertyId);
+ if (payment.folioId) {
+ await this.folioService.recalculateBalance(payment.folioId, payment.propertyId);
+ }
await this.webhookService.emit(
'payment.failed',
@@ -185,6 +223,11 @@ export class StripeWebhookController {
const payment = await this.resolvePaymentForIntent(pi);
if (!payment) return;
+ if (this.shouldDelegateToBookingRequestHandler(payment)) {
+ await this.bookingRequestStripeHandler!.handlePaymentIntentCanceled(pi, payment);
+ return;
+ }
+
if (payment.status === 'voided') return;
await this.db
@@ -193,7 +236,9 @@ export class StripeWebhookController {
.where(and(eq(payments.id, payment.id), eq(payments.propertyId, payment.propertyId)));
// Recalculate folio balance after payment state change
- await this.folioService.recalculateBalance(payment.folioId, payment.propertyId);
+ if (payment.folioId) {
+ await this.folioService.recalculateBalance(payment.folioId, payment.propertyId);
+ }
await this.webhookService.emit(
'payment.failed',
@@ -206,6 +251,45 @@ export class StripeWebhookController {
this.logger.log(`Payment ${payment.id} updated to voided via webhook`);
}
+ private async handlePaymentIntentProcessing(pi: Stripe.PaymentIntent) {
+ if (!this.bookingRequestStripeHandler) return;
+ const payment = await this.findPaymentByGatewayTransactionId(pi.id);
+ if (payment && !this.shouldDelegateToBookingRequestHandler(payment)) return;
+ await this.bookingRequestStripeHandler.handlePaymentIntentProcessing(
+ pi,
+ payment ?? this.placeholderPaymentRow(),
+ );
+ }
+
+ private async handlePaymentIntentRequiresAction(pi: Stripe.PaymentIntent) {
+ if (!this.bookingRequestStripeHandler) return;
+ const payment = await this.findPaymentByGatewayTransactionId(pi.id);
+ if (payment && !this.shouldDelegateToBookingRequestHandler(payment)) return;
+ await this.bookingRequestStripeHandler.handlePaymentIntentRequiresAction(
+ pi,
+ payment ?? this.placeholderPaymentRow(),
+ );
+ }
+
+ private async handleRefundUpdated(refund: Stripe.Refund) {
+ if (!this.bookingRequestStripeHandler) return;
+ await this.bookingRequestStripeHandler.handleRefundUpdated(refund);
+ }
+
+ private placeholderPaymentRow(): BookingRequestStripePaymentRow {
+ return {
+ id: '',
+ propertyId: '',
+ folioId: null,
+ status: 'pending',
+ amount: '0.00',
+ currencyCode: 'USD',
+ method: 'credit_card',
+ gatewayProvider: 'stripe',
+ gatewayTransactionId: null,
+ };
+ }
+
private async handleChargeRefunded(charge: Stripe.Charge) {
const piId = typeof charge.payment_intent === 'string'
? charge.payment_intent
@@ -216,6 +300,11 @@ export class StripeWebhookController {
const payment = await this.findPaymentByGatewayTransactionId(piId);
if (!payment) return;
+ if (this.shouldDelegateToBookingRequestHandler(payment)) {
+ await this.bookingRequestStripeHandler!.handleChargeRefunded(charge, payment);
+ return;
+ }
+
const stripeRefundedDec = new Decimal(charge.amount_refunded).div(100);
const ledgerKey = `stripe_refund:${charge.id}:${stripeRefundedDec.toFixed(2)}`;
@@ -324,6 +413,12 @@ export class StripeWebhookController {
.select()
.from(payments)
.where(eq(payments.gatewayTransactionId, transactionId));
- return payment ?? null;
+ return (payment ?? null) as BookingRequestStripePaymentRow | null;
+ }
+
+ private shouldDelegateToBookingRequestHandler(
+ payment: BookingRequestStripePaymentRow,
+ ): payment is BookingRequestStripePaymentRow & { bookingRequestId: string } {
+ return paymentHasBookingRequestId(payment) && !!this.bookingRequestStripeHandler;
}
}
diff --git a/apps/api/src/modules/payment/unsupported-saved-payment-method.gateway.ts b/apps/api/src/modules/payment/unsupported-saved-payment-method.gateway.ts
new file mode 100644
index 00000000..fedaa4ea
--- /dev/null
+++ b/apps/api/src/modules/payment/unsupported-saved-payment-method.gateway.ts
@@ -0,0 +1,43 @@
+import type {
+ SavedPaymentMethod,
+ SavedPaymentMethodChargeInput,
+ SavedPaymentMethodChargeResult,
+ SavedPaymentMethodGateway,
+ SavedPaymentMethodProvenance,
+} from './interfaces/saved-payment-method-gateway.interface';
+import type { PaymentGatewayProvider } from './payment-gateway.factory';
+
+export class UnsupportedSavedPaymentMethodGateway implements SavedPaymentMethodGateway {
+ constructor(private readonly provider: Exclude) {}
+
+ async createSetup(
+ _email: string,
+ _idempotencyKey: string,
+ _provenance: SavedPaymentMethodProvenance,
+ ): Promise<{
+ setupIntentId: string;
+ clientSecret: string;
+ customerId: string;
+ clientMode: 'mock' | 'stripe';
+ }> {
+ throw this.unsupported();
+ }
+
+ async resolveSetup(
+ _setupIntentId: string,
+ _expectedProvenance: SavedPaymentMethodProvenance,
+ ): Promise {
+ throw this.unsupported();
+ }
+
+ async charge(_input: SavedPaymentMethodChargeInput): Promise {
+ throw this.unsupported();
+ }
+
+ private unsupported(): Error {
+ return new Error(
+ `Saved payment methods are not supported when PAYMENT_GATEWAY='${this.provider}'. ` +
+ `Configure PAYMENT_GATEWAY='stripe' to use this capability.`,
+ );
+ }
+}
diff --git a/apps/api/src/modules/policy/policy.service.ts b/apps/api/src/modules/policy/policy.service.ts
index b161fbb7..f84bc627 100644
--- a/apps/api/src/modules/policy/policy.service.ts
+++ b/apps/api/src/modules/policy/policy.service.ts
@@ -173,8 +173,9 @@ export class PolicyService {
/**
* Resolve the linked policy for a rate plan (property-scoped), or the default heuristic.
*/
- async resolvePolicyForRatePlan(propertyId: string, ratePlanId: string) {
- const [ratePlan] = await this.db
+ async resolvePolicyForRatePlan(propertyId: string, ratePlanId: string, db?: any) {
+ const conn = db ?? this.db;
+ const [ratePlan] = await conn
.select()
.from(ratePlans)
.where(and(eq(ratePlans.id, ratePlanId), eq(ratePlans.propertyId, propertyId)));
@@ -183,7 +184,7 @@ export class PolicyService {
}
if (ratePlan.cancellationPolicyId) {
- const [policy] = await this.db
+ const [policy] = await conn
.select()
.from(cancellationPolicies)
.where(
@@ -202,8 +203,8 @@ export class PolicyService {
}
/** Guest-facing summary for search / quote / book responses. */
- async getPolicySummary(propertyId: string, ratePlanId: string) {
- const { policy } = await this.resolvePolicyForRatePlan(propertyId, ratePlanId);
+ async getPolicySummary(propertyId: string, ratePlanId: string, db?: any) {
+ const { policy } = await this.resolvePolicyForRatePlan(propertyId, ratePlanId, db);
const p = policy ?? DEFAULT_POLICY;
const type =
p.penaltyType === 'full' && (p.freeCancelHoursBeforeArrival ?? 0) === 0
diff --git a/apps/api/src/modules/rate-plan/rate-plan.service.ts b/apps/api/src/modules/rate-plan/rate-plan.service.ts
index 911e4da3..84582159 100644
--- a/apps/api/src/modules/rate-plan/rate-plan.service.ts
+++ b/apps/api/src/modules/rate-plan/rate-plan.service.ts
@@ -60,7 +60,9 @@ export class RatePlanService {
ratePlanId: string,
checkIn: string,
checkOut: string,
+ db?: any,
): Promise {
+ const conn = db ?? this.db;
const nights = Math.ceil(
(new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86_400_000,
);
@@ -69,7 +71,7 @@ export class RatePlanService {
}
// Plan lookup is scoped by both ids — never infer propertyId from the row.
- const [plan] = await this.db
+ const [plan] = await conn
.select()
.from(ratePlans)
.where(and(eq(ratePlans.id, ratePlanId), eq(ratePlans.propertyId, propertyId)));
@@ -89,7 +91,7 @@ export class RatePlanService {
}
// Restrictions overlapping the stay (scoped by property — multi-tenancy).
- const restrictions = await this.db
+ const restrictions = await conn
.select()
.from(rateRestrictions)
.where(
@@ -218,11 +220,13 @@ export class RatePlanService {
);
}
- async findById(id: string, propertyId: string) {
- const [ratePlan] = await this.db
+ async findById(id: string, propertyId: string, db?: any, lockForUpdate = false) {
+ const conn = db ?? this.db;
+ const query = conn
.select()
.from(ratePlans)
.where(and(eq(ratePlans.id, id), eq(ratePlans.propertyId, propertyId)));
+ const [ratePlan] = lockForUpdate ? await query.for('update') : await query;
if (!ratePlan) {
throw new NotFoundException(`Rate plan ${id} not found`);
}
@@ -291,14 +295,21 @@ export class RatePlanService {
id: string,
propertyId: string,
context?: EffectiveRateQueryDto,
+ db?: any,
+ lockForUpdate = false,
): Promise {
- const ratePlan = await this.findById(id, propertyId);
+ const ratePlan = await this.findById(id, propertyId, db, lockForUpdate);
let baseRate: number;
if (ratePlan.type !== 'derived' || !ratePlan.parentRatePlanId) {
baseRate = Number(ratePlan.baseAmount);
} else {
- const parent = await this.findById(ratePlan.parentRatePlanId, propertyId);
+ const parent = await this.findById(
+ ratePlan.parentRatePlanId,
+ propertyId,
+ db,
+ lockForUpdate,
+ );
const parentAmount = Number(parent.baseAmount);
const adjustmentValue = Number(ratePlan.derivedAdjustmentValue);
@@ -327,7 +338,7 @@ export class RatePlanService {
let occupancyPct: number | undefined;
let occupancyAdjustment: OccupancyBand | null = null;
if (stayDate && ratePlan.occupancyBands?.length) {
- occupancyPct = await this.getStayOccupancyPct(propertyId, stayDate);
+ occupancyPct = await this.getStayOccupancyPct(propertyId, stayDate, db);
occupancyAdjustment = selectOccupancyBand(
ratePlan.occupancyBands as OccupancyBand[],
occupancyPct,
@@ -351,14 +362,15 @@ export class RatePlanService {
/**
* Projected occupancy % for a stay date (confirmed/in-house reservations).
*/
- async getStayOccupancyPct(propertyId: string, stayDate: string): Promise {
- const [property] = await this.db
+ async getStayOccupancyPct(propertyId: string, stayDate: string, db?: any): Promise {
+ const conn = db ?? this.db;
+ const [property] = await conn
.select({ totalRooms: properties.totalRooms })
.from(properties)
.where(eq(properties.id, propertyId));
const totalRooms = property?.totalRooms ?? 0;
- const roomStatusCounts = await this.db
+ const roomStatusCounts = await conn
.select({
status: rooms.status,
count: sql`count(*)::int`,
@@ -375,7 +387,7 @@ export class RatePlanService {
}
const availableRooms = totalRooms - unavailableRooms;
- const [soldResult] = await this.db
+ const [soldResult] = await conn
.select({ count: sql`count(distinct ${reservations.id})::int` })
.from(reservations)
.where(
diff --git a/apps/api/src/modules/reports/reports.service.spec.ts b/apps/api/src/modules/reports/reports.service.spec.ts
index a98d4b9d..fd6f8367 100644
--- a/apps/api/src/modules/reports/reports.service.spec.ts
+++ b/apps/api/src/modules/reports/reports.service.spec.ts
@@ -89,7 +89,7 @@ describe('ReportsService', () => {
{ type: 'food_beverage', total: '500.00' },
],
// adjustments (reversals)
- [{ total: '50.00' }],
+ [{ total: '-50.00' }],
// payments by method
[
{ method: 'credit_card', total: '3500.00' },
@@ -113,6 +113,29 @@ describe('ReportsService', () => {
expect(result.netRevenue).toBe(3750);
});
+ it('nets a fully reversed signed accepted-pricing group to zero revenue', async () => {
+ const db = createMockDb([
+ // Original 100 plus a -20 amendment correction remain immutable revenue.
+ [{ type: 'room', total: '80.00' }],
+ // Drizzle returns the signed sum: -100 plus +20.
+ [{ total: '-80.00' }],
+ [],
+ ]);
+ const module = await Test.createTestingModule({
+ providers: [
+ ReportsService,
+ { provide: DRIZZLE, useValue: db },
+ ],
+ }).compile();
+
+ const result = await module.get(ReportsService)
+ .getDailyRevenue('prop-001', '2026-04-06');
+
+ expect(result.revenue.room).toBe(80);
+ expect(result.adjustments).toBe(80);
+ expect(result.netRevenue).toBe(0);
+ });
+
it('should sum payments by method', async () => {
const db = createMockDb([
[], // no charges
diff --git a/apps/api/src/modules/reports/reports.service.ts b/apps/api/src/modules/reports/reports.service.ts
index eda8da43..7fdc8e28 100644
--- a/apps/api/src/modules/reports/reports.service.ts
+++ b/apps/api/src/modules/reports/reports.service.ts
@@ -103,7 +103,10 @@ export class ReportsService {
paymentsTotalDec = paymentsTotalDec.plus(amount);
}
- const adjustmentsDec = new Decimal(adjResult?.total ?? '0');
+ const signedReversalTotal = new Decimal(adjResult?.total ?? '0');
+ const adjustmentsDec = signedReversalTotal.isZero()
+ ? new Decimal(0)
+ : signedReversalTotal.negated();
return {
date,
diff --git a/apps/api/src/modules/reservation/availability.service.spec.ts b/apps/api/src/modules/reservation/availability.service.spec.ts
index cd59b404..9f38df33 100644
--- a/apps/api/src/modules/reservation/availability.service.spec.ts
+++ b/apps/api/src/modules/reservation/availability.service.spec.ts
@@ -19,6 +19,37 @@ function availabilityDb(stages: Array<{ rows: any[]; groupBy?: boolean; innerJoi
}
describe('AvailabilityService', () => {
+ it('excludes only the explicitly scoped current reservation from the complete window', async () => {
+ const db = availabilityDb([
+ { rows: [{ id: 'prop-1', overbookingPercentage: 0 }] },
+ { rows: [{ id: 'rt-1', name: 'Standard', maxOccupancy: 2 }] },
+ {
+ rows: [
+ { id: 'res-current', propertyId: 'prop-1', roomTypeId: 'rt-1', arrivalDate: '2026-09-01', departureDate: '2026-09-03' },
+ { id: 'res-other', propertyId: 'prop-1', roomTypeId: 'rt-1', arrivalDate: '2026-09-01', departureDate: '2026-09-03' },
+ ],
+ },
+ { rows: [{ roomTypeId: 'rt-1', count: 2 }], groupBy: true },
+ { rows: [], innerJoin: true },
+ ]);
+ const service = new AvailabilityService(db as any);
+
+ const results = await service.searchAvailability(
+ 'prop-1',
+ '2026-09-01',
+ '2026-09-03',
+ 'rt-1',
+ undefined,
+ { excludeReservationId: 'res-current' },
+ );
+
+ expect(results.map((row) => ({ date: row.date, sold: row.sold, available: row.available })))
+ .toEqual([
+ { date: '2026-09-01', sold: 1, available: 1 },
+ { date: '2026-09-02', sold: 1, available: 1 },
+ ]);
+ });
+
it('reduces availability with active imported iCal blocks per distinct feed/date', async () => {
const db = availabilityDb([
{ rows: [{ id: 'prop-1', overbookingPercentage: 0 }] },
diff --git a/apps/api/src/modules/reservation/availability.service.ts b/apps/api/src/modules/reservation/availability.service.ts
index b01a6a84..aac331a3 100644
--- a/apps/api/src/modules/reservation/availability.service.ts
+++ b/apps/api/src/modules/reservation/availability.service.ts
@@ -1,6 +1,7 @@
-import { Injectable, Inject } from '@nestjs/common';
-import { eq, and, notInArray, sql, lt, gt } from 'drizzle-orm';
+import { BadRequestException, Injectable, Inject } from '@nestjs/common';
+import { eq, and, ne, notInArray, sql, lt, gt } from 'drizzle-orm';
import { reservations, roomTypes, properties, rooms, icalBlocks, icalFeeds } from '@telivityhaip/database';
+import { stayDates } from '@telivityhaip/shared';
import { DRIZZLE } from '../../database/database.module';
export interface AvailabilityResult {
@@ -13,6 +14,32 @@ export interface AvailabilityResult {
overbookingBuffer: number;
}
+/** Re-exported for existing call sites; canonical definition lives in @telivityhaip/shared. */
+export { stayDates };
+
+/** Require one positive, property-calculated availability row for every night. */
+export function assertFullStayAvailability(
+ rows: AvailabilityResult[],
+ roomTypeId: string,
+ checkIn: string,
+ checkOut: string,
+): void {
+ const byDate = new Map(
+ rows
+ .filter((row) => row.roomTypeId === roomTypeId)
+ .map((row) => [row.date, row]),
+ );
+ const unavailable = stayDates(checkIn, checkOut).find((date) => {
+ const row = byDate.get(date);
+ return !row || row.available <= 0;
+ });
+ if (unavailable) {
+ throw new BadRequestException(
+ `No availability for room type ${roomTypeId} on ${unavailable}`,
+ );
+ }
+}
+
@Injectable()
export class AvailabilityService {
constructor(@Inject(DRIZZLE) private readonly db: any) {}
@@ -28,8 +55,10 @@ export class AvailabilityService {
checkOut: string,
roomTypeId?: string,
db?: any,
+ options?: { excludeReservationId?: string },
): Promise {
const conn = db ?? this.db;
+ const requestedDates = stayDates(checkIn, checkOut);
// Get property overbooking config
const [property] = await conn
@@ -56,6 +85,8 @@ export class AvailabilityService {
const excludedStatuses = ['cancelled', 'no_show', 'checked_out'] as const;
const overlapping = await conn
.select({
+ id: reservations.id,
+ propertyId: reservations.propertyId,
roomTypeId: reservations.roomTypeId,
arrivalDate: reservations.arrivalDate,
departureDate: reservations.departureDate,
@@ -69,8 +100,14 @@ export class AvailabilityService {
sql`${reservations.arrivalDate} < ${checkOut}`,
sql`${reservations.departureDate} > ${checkIn}`,
...(roomTypeId ? [eq(reservations.roomTypeId, roomTypeId)] : []),
+ ...(options?.excludeReservationId
+ ? [ne(reservations.id, options.excludeReservationId)]
+ : []),
),
);
+ const scopedOverlapping = overlapping.filter((reservation: any) =>
+ (reservation.propertyId == null || reservation.propertyId === propertyId)
+ && (reservation.id == null || reservation.id !== options?.excludeReservationId));
// Single grouped query for room counts per room type (avoids N+1).
const roomCountRows = await conn
@@ -122,23 +159,15 @@ export class AvailabilityService {
// Generate date-level availability
const results: AvailabilityResult[] = [];
- const startDate = new Date(checkIn);
- const endDate = new Date(checkOut);
-
for (const type of types) {
const totalRooms = type.maxOccupancy
? (roomCountByType.get(type.id) ?? 0)
: 0;
- for (
- let d = new Date(startDate);
- d < endDate;
- d.setDate(d.getDate() + 1)
- ) {
- const dateStr = d.toISOString().split('T')[0]!;
+ for (const dateStr of requestedDates) {
// Count reservations occupying this room type on this date
- const sold = overlapping.filter(
+ const sold = scopedOverlapping.filter(
(r: any) =>
r.roomTypeId === type.id &&
r.arrivalDate <= dateStr &&
diff --git a/apps/api/src/modules/reservation/dto/modify-reservation.dto.ts b/apps/api/src/modules/reservation/dto/modify-reservation.dto.ts
index c9f5a9b8..c7d7e097 100644
--- a/apps/api/src/modules/reservation/dto/modify-reservation.dto.ts
+++ b/apps/api/src/modules/reservation/dto/modify-reservation.dto.ts
@@ -1,15 +1,16 @@
-import { IsUUID, IsDateString, IsInt, IsOptional, IsString, IsBoolean, Min, MaxLength } from 'class-validator';
+import { IsUUID, IsInt, IsOptional, IsString, IsBoolean, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IsCanonicalCalendarDate } from '@telivityhaip/shared';
export class ModifyReservationDto {
@ApiPropertyOptional({ example: '2024-06-02' })
@IsOptional()
- @IsDateString()
+ @IsCanonicalCalendarDate()
arrivalDate?: string;
@ApiPropertyOptional({ example: '2024-06-06' })
@IsOptional()
- @IsDateString()
+ @IsCanonicalCalendarDate()
departureDate?: string;
@ApiPropertyOptional()
diff --git a/apps/api/src/modules/reservation/reservation-assert-sellable.spec.ts b/apps/api/src/modules/reservation/reservation-assert-sellable.spec.ts
index 8b4a0a11..73e52842 100644
--- a/apps/api/src/modules/reservation/reservation-assert-sellable.spec.ts
+++ b/apps/api/src/modules/reservation/reservation-assert-sellable.spec.ts
@@ -12,12 +12,20 @@ import { AncillaryService } from '../ancillary/ancillary.service';
import { PolicyService } from '../policy/policy.service';
import { DepositSettlementService } from '../accounting/deposit-settlement.service';
import { RatePlanService } from '../rate-plan/rate-plan.service';
+import {
+ bookings,
+ reservationGuests,
+ reservations,
+} from '@telivityhaip/database';
+import { validate } from 'class-validator';
+import { ModifyReservationDto } from './dto/modify-reservation.dto';
const PROPERTY = 'aaaaaaaa-0000-4000-a000-000000000001';
const RATE_PLAN = 'rp-001';
const ROOM_TYPE = 'rt-001';
function mkDb() {
+ const inventoryLock = vi.fn().mockResolvedValue([{ id: ROOM_TYPE }]);
return {
select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnValue({
@@ -34,6 +42,11 @@ function mkDb() {
update: vi.fn(),
transaction: vi.fn().mockImplementation(async (cb: any) =>
cb({
+ select: vi.fn().mockReturnValue({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockReturnValue({ for: inventoryLock }),
+ }),
+ }),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ id: 'res-1', arrivalDate: '2026-07-01' }]),
@@ -46,7 +59,10 @@ function mkDb() {
async function mkService(assertSellable: ReturnType, db = mkDb()) {
const availability = {
- searchAvailability: vi.fn().mockResolvedValue([{ roomTypeId: ROOM_TYPE, available: 2 }]),
+ searchAvailability: vi.fn().mockResolvedValue([
+ { roomTypeId: ROOM_TYPE, date: '2026-07-01', available: 2 },
+ { roomTypeId: ROOM_TYPE, date: '2026-07-02', available: 2 },
+ ]),
};
const mod = await Test.createTestingModule({
providers: [
@@ -129,4 +145,306 @@ describe('ReservationService.create — assertSellable (BOOK path)', () => {
expect(db.transaction).not.toHaveBeenCalled();
});
+
+ it('rejects creation when any night in the complete stay is unavailable', async () => {
+ const assertSellable = vi.fn().mockResolvedValue(undefined);
+ const { svc, availability } = await mkService(assertSellable);
+ availability.searchAvailability.mockResolvedValue([
+ { roomTypeId: ROOM_TYPE, date: '2026-07-01', available: 1 },
+ ]);
+
+ await expect(svc.create({
+ propertyId: PROPERTY,
+ roomTypeId: ROOM_TYPE,
+ ratePlanId: RATE_PLAN,
+ arrivalDate: '2026-07-01',
+ departureDate: '2026-07-03',
+ totalAmount: '300.00',
+ currencyCode: 'USD',
+ guestId: 'g',
+ } as any)).rejects.toThrow(/2026-07-02/);
+ });
+
+ it('rejects a modified stay when the availability result omits a new night', async () => {
+ const { svc, availability } = await mkService(
+ vi.fn().mockResolvedValue(undefined),
+ );
+ vi.spyOn(svc as any, 'findByIdRaw').mockResolvedValue({
+ id: 'res-1',
+ propertyId: PROPERTY,
+ status: 'confirmed',
+ arrivalDate: '2026-07-01',
+ departureDate: '2026-07-03',
+ roomTypeId: ROOM_TYPE,
+ ratePlanId: RATE_PLAN,
+ });
+ availability.searchAvailability.mockResolvedValue([
+ { roomTypeId: ROOM_TYPE, date: '2026-07-01', available: 0 },
+ { roomTypeId: ROOM_TYPE, date: '2026-07-02', available: 0 },
+ ]);
+
+ await expect(svc.modify('res-1', PROPERTY, {
+ departureDate: '2026-07-04',
+ } as any)).rejects.toThrow(/2026-07-03/);
+ });
+
+ it.each([
+ [{ departureDate: '2026-07-04' }, 'stay dates'],
+ [{ totalAmount: '325.00' }, 'accepted total'],
+ [{ ratePlanId: 'rp-002' }, 'rate plan'],
+ [{ roomTypeId: 'rt-002' }, 'room type'],
+ [{ adults: 3 }, 'occupancy'],
+ ] as const)(
+ 'requires a Stay Amendment before changing accepted pricing via %s',
+ async (change, _label) => {
+ const { svc, db } = await mkService(vi.fn().mockResolvedValue(undefined));
+ vi.spyOn(svc as any, 'findByIdRaw').mockResolvedValue({
+ id: 'res-1',
+ propertyId: PROPERTY,
+ status: 'confirmed',
+ arrivalDate: '2026-07-01',
+ departureDate: '2026-07-03',
+ roomTypeId: ROOM_TYPE,
+ ratePlanId: RATE_PLAN,
+ totalAmount: '300.00',
+ adults: 2,
+ children: 0,
+ acceptedPricingSnapshot: { version: 1, source: 'submitted' },
+ });
+
+ await expect(svc.modify('res-1', PROPERTY, change as any)).rejects.toThrow(
+ /Stay Amendment.*accepted pricing/i,
+ );
+ expect(db.transaction).not.toHaveBeenCalled();
+ },
+ );
+
+ it('retains safe metadata edits on an accepted-pricing reservation', async () => {
+ const updated = {
+ id: 'res-1',
+ propertyId: PROPERTY,
+ status: 'confirmed',
+ arrivalDate: '2026-07-01',
+ departureDate: '2026-07-03',
+ roomTypeId: ROOM_TYPE,
+ ratePlanId: RATE_PLAN,
+ totalAmount: '300.00',
+ adults: 2,
+ children: 0,
+ specialRequests: 'Late arrival',
+ doNotMove: true,
+ acceptedPricingSnapshot: { version: 1, source: 'submitted' },
+ };
+ const returning = vi.fn().mockResolvedValue([updated]);
+ const where = vi.fn().mockReturnValue({ returning });
+ const set = vi.fn().mockReturnValue({ where });
+ const update = vi.fn().mockReturnValue({ set });
+ const db = mkDb();
+ db.transaction.mockImplementation(async (callback: (tx: any) => Promise) =>
+ callback({ update }));
+ const { svc } = await mkService(vi.fn().mockResolvedValue(undefined), db);
+ vi.spyOn(svc as any, 'findByIdRaw').mockResolvedValue({
+ ...updated,
+ specialRequests: null,
+ doNotMove: false,
+ });
+
+ const result = await svc.modify('res-1', PROPERTY, {
+ specialRequests: 'Late arrival',
+ doNotMove: true,
+ });
+
+ expect(result).toMatchObject({
+ reservation: {
+ specialRequests: 'Late arrival',
+ doNotMove: true,
+ acceptedPricingSnapshot: expect.any(Object),
+ },
+ previousArrivalDate: '2026-07-01',
+ previousDepartureDate: '2026-07-03',
+ previousTotalAmount: '300.00',
+ newTotalAmount: '300.00',
+ });
+ expect(set).toHaveBeenCalledWith(expect.objectContaining({
+ specialRequests: 'Late arrival',
+ doNotMove: true,
+ }));
+ });
+
+ it('updates accepted dates, total, and pricing only through the locked amendment seam', async () => {
+ const previousSnapshot = {
+ version: 1 as const,
+ source: 'current' as const,
+ currencyCode: 'USD',
+ grandTotal: '300.00',
+ roomTotal: '270.00',
+ taxTotal: '30.00',
+ nights: [
+ { date: '2026-07-01', roomAmount: '135.00', taxAmount: '15.00' },
+ { date: '2026-07-02', roomAmount: '135.00', taxAmount: '15.00' },
+ ],
+ services: [],
+ servicesTotal: '0.00',
+ servicesTaxTotal: '0.00',
+ customReason: null,
+ adjustment: null,
+ };
+ const nextSnapshot = {
+ ...structuredClone(previousSnapshot),
+ source: 'prior' as const,
+ grandTotal: '450.00',
+ roomTotal: '405.00',
+ taxTotal: '45.00',
+ nights: [
+ ...previousSnapshot.nights,
+ { date: '2026-07-03', roomAmount: '135.00', taxAmount: '15.00' },
+ ],
+ };
+ const locked = {
+ id: 'res-1',
+ propertyId: PROPERTY,
+ status: 'confirmed',
+ arrivalDate: '2026-07-01',
+ departureDate: '2026-07-03',
+ nights: 2,
+ roomTypeId: ROOM_TYPE,
+ ratePlanId: RATE_PLAN,
+ totalAmount: '300.00',
+ currencyCode: 'USD',
+ acceptedPricingSnapshot: previousSnapshot,
+ };
+ const updated = {
+ ...locked,
+ departureDate: '2026-07-04',
+ nights: 3,
+ totalAmount: '450.00',
+ acceptedPricingSnapshot: nextSnapshot,
+ };
+ const returning = vi.fn().mockResolvedValue([updated]);
+ const where = vi.fn().mockReturnValue({ returning });
+ const set = vi.fn().mockReturnValue({ where });
+ const tx = { update: vi.fn().mockReturnValue({ set }) };
+ const { svc, availability } = await mkService(vi.fn().mockResolvedValue(undefined));
+
+ const result = await svc.modifyAcceptedStay(
+ locked as any,
+ PROPERTY,
+ {
+ arrivalDate: '2026-07-01',
+ departureDate: '2026-07-04',
+ totalAmount: '450.00',
+ },
+ nextSnapshot,
+ tx,
+ );
+
+ expect(result).toEqual({
+ reservation: updated,
+ previousArrivalDate: '2026-07-01',
+ previousDepartureDate: '2026-07-03',
+ previousTotalAmount: '300.00',
+ newTotalAmount: '450.00',
+ });
+ expect(set).toHaveBeenCalledWith(expect.objectContaining({
+ departureDate: '2026-07-04',
+ nights: 3,
+ totalAmount: '450.00',
+ acceptedPricingSnapshot: nextSnapshot,
+ }));
+ expect(availability.searchAvailability).not.toHaveBeenCalled();
+ });
+
+ it('requires canonical date-only values in the generic modification DTO', async () => {
+ const dto = Object.assign(new ModifyReservationDto(), {
+ arrivalDate: '2026-07-01T10:00:00.000Z',
+ departureDate: '2026-07-03',
+ });
+ const errors = await validate(dto);
+ expect(errors.some((error) => error.property === 'arrivalDate')).toBe(true);
+ });
+
+ it('serializes two canonical creates competing for the final room', async () => {
+ let reservationCount = 0;
+ let bookingCount = 0;
+ let lockQueue = Promise.resolve();
+ const db: any = {
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(async () => [{ id: 'owned', isDnr: false }]),
+ })),
+ })),
+ transaction: vi.fn(async (callback: (tx: any) => Promise) => {
+ let release = () => undefined;
+ const previous = lockQueue;
+ lockQueue = new Promise((resolve) => {
+ release = resolve;
+ });
+ const tx = {
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ for: vi.fn(async () => {
+ await previous;
+ return [{ id: ROOM_TYPE }];
+ }),
+ })),
+ })),
+ })),
+ insert: vi.fn((table: unknown) => ({
+ values: vi.fn((values: Record) => {
+ if (table === reservationGuests) return Promise.resolve();
+ return {
+ returning: vi.fn(async () => {
+ if (table === bookings) {
+ bookingCount += 1;
+ return [{ id: `booking-${bookingCount}`, ...values }];
+ }
+ if (table === reservations) {
+ reservationCount += 1;
+ return [{ id: `reservation-${reservationCount}`, ...values }];
+ }
+ return [];
+ }),
+ };
+ }),
+ })),
+ };
+ try {
+ return await callback(tx);
+ } finally {
+ release();
+ }
+ }),
+ };
+ const { svc, availability } = await mkService(
+ vi.fn().mockResolvedValue(undefined),
+ db,
+ );
+ availability.searchAvailability.mockImplementation(async () => [
+ { roomTypeId: ROOM_TYPE, date: '2026-07-01', available: reservationCount === 0 ? 1 : 0 },
+ { roomTypeId: ROOM_TYPE, date: '2026-07-02', available: reservationCount === 0 ? 1 : 0 },
+ ]);
+ const dto = {
+ propertyId: PROPERTY,
+ roomTypeId: ROOM_TYPE,
+ ratePlanId: RATE_PLAN,
+ arrivalDate: '2026-07-01',
+ departureDate: '2026-07-03',
+ totalAmount: '300.00',
+ currencyCode: 'USD',
+ guestId: 'g',
+ source: 'direct',
+ } as any;
+
+ const results = await Promise.allSettled([
+ svc.create(dto),
+ svc.create(dto),
+ ]);
+
+ expect(results.map((result) => result.status).sort()).toEqual([
+ 'fulfilled',
+ 'rejected',
+ ]);
+ expect(reservationCount).toBe(1);
+ });
});
diff --git a/apps/api/src/modules/reservation/reservation.controller.ts b/apps/api/src/modules/reservation/reservation.controller.ts
index bb254177..bf809ac9 100644
--- a/apps/api/src/modules/reservation/reservation.controller.ts
+++ b/apps/api/src/modules/reservation/reservation.controller.ts
@@ -213,12 +213,13 @@ export class ReservationController {
@ApiQuery({ name: 'propertyId', required: true })
@ApiResponse({ status: 200, description: 'Reservation modified' })
@ApiResponse({ status: 404, description: 'Reservation not found' })
- modifyReservation(
+ async modifyReservation(
@Param('id', ParseUUIDPipe) id: string,
@Query('propertyId', ParseUUIDPipe) propertyId: string,
@Body() dto: ModifyReservationDto,
) {
- return this.reservationService.modify(id, propertyId, dto);
+ const result = await this.reservationService.modify(id, propertyId, dto);
+ return result.reservation;
}
// --- Lifecycle transition routes ---
diff --git a/apps/api/src/modules/reservation/reservation.service.ts b/apps/api/src/modules/reservation/reservation.service.ts
index 3b4bfa53..6e723da2 100644
--- a/apps/api/src/modules/reservation/reservation.service.ts
+++ b/apps/api/src/modules/reservation/reservation.service.ts
@@ -11,7 +11,11 @@ import Decimal from 'decimal.js';
import { reservations, reservationGuests, bookings, guests, rooms, roomTypes, ratePlans, properties, payments } from '@telivityhaip/database';
import { DRIZZLE } from '../../database/database.module';
import { assertTransition, type ReservationStatus } from './reservation-state-machine';
-import { AvailabilityService } from './availability.service';
+import {
+ assertFullStayAvailability,
+ AvailabilityService,
+ stayDates,
+} from './availability.service';
import { FolioService } from '../folio/folio.service';
import { RoomStatusService } from '../room/room-status.service';
import { PaymentService } from '../payment/payment.service';
@@ -32,7 +36,19 @@ import { CheckOutDto } from './dto/check-out.dto';
import { GroupCheckInDto } from './dto/group-check-in.dto';
import { BulkActionDto } from './dto/bulk-action.dto';
import { ListUnassignedDto } from './dto/list-unassigned.dto';
-import { randomUUID, createCipheriv, randomBytes } from 'crypto';
+import { createCipheriv, randomBytes } from 'crypto';
+import { generateConfirmationNumber } from '../../common/crypto/confirmation-number';
+import type { AcceptedPricingSnapshot } from '@telivityhaip/database';
+
+type ReservationRow = typeof reservations.$inferSelect;
+
+export type ReservationAmendmentResult = {
+ reservation: ReservationRow;
+ previousArrivalDate: string;
+ previousDepartureDate: string;
+ previousTotalAmount: string;
+ newTotalAmount: string;
+};
@Injectable()
export class ReservationService {
@@ -50,9 +66,17 @@ export class ReservationService {
private readonly ratePlanService: RatePlanService,
) {}
- async create(dto: CreateReservationDto, opts?: { confirmationNumber?: string }) {
+ async create(
+ dto: CreateReservationDto,
+ opts?: {
+ confirmationNumber?: string;
+ acceptedPricingSnapshot?: AcceptedPricingSnapshot;
+ },
+ tx?: any,
+ ) {
+ const db = tx ?? this.db;
// Check guest is not DNR
- const [guest] = await this.db
+ const [guest] = await db
.select()
.from(guests)
.where(eq(guests.id, dto.guestId));
@@ -75,51 +99,74 @@ export class ReservationService {
throw new BadRequestException('Departure date must be after arrival date');
}
- // Generate confirmation number. Callers that expose it to guests as a bearer
- // credential (e.g. the booking engine) inject a high-entropy value instead of
- // the default timestamp form, which is too low-entropy to be unguessable.
- const confirmationNumber =
- opts?.confirmationNumber ??
- `HAIP-${Date.now().toString(36).toUpperCase()}-${randomUUID().slice(0, 4).toUpperCase()}`;
+ // Every confirmation number is a bearer credential. Use the same 128-bit
+ // generator for direct, staff, channel, and fallback canonical callers.
+ const confirmationNumber = opts?.confirmationNumber ?? generateConfirmationNumber();
// FK ownership (security audit #4): the caller supplies roomTypeId AND
// ratePlanId in the DTO. Without scoping these to dto.propertyId, a caller
// at property A could reference property B's rate plan / room type and
// leak its details back on read. Verify same-property before any insert.
- await this.assertSamePropertyFk(roomTypes, dto.roomTypeId, dto.propertyId, 'room type');
- await this.assertSamePropertyFk(ratePlans, dto.ratePlanId, dto.propertyId, 'rate plan');
-
- // RatePlanService.assertSellable docs: BOOK path MUST call this. PMS create
- // was the gap — Connect / booking-engine already gate; keep propertyId scoped.
- await this.ratePlanService.assertSellable(
+ await this.assertSamePropertyFk(
+ roomTypes,
+ dto.roomTypeId,
dto.propertyId,
+ 'room type',
+ db,
+ );
+ await this.assertSamePropertyFk(
+ ratePlans,
dto.ratePlanId,
- dto.arrivalDate,
- dto.departureDate,
+ dto.propertyId,
+ 'rate plan',
+ db,
);
- // TOCTOU: availability check + insert run inside the same transaction so the
- // race window between "there's space" and "we wrote the booking" is minimized.
- // Postgres default isolation is READ COMMITTED, so concurrent txs can still
- // double-book in theory; for stronger guarantees promote to SERIALIZABLE.
- // See Bug 5 — kept at default to avoid driver-compat surprises.
- const result = await this.db.transaction(async (tx: any) => {
+ // RatePlanService.assertSellable docs: BOOK path MUST call this. PMS create
+ // was the gap — Connect / booking-engine already gate; keep propertyId scoped.
+ if (tx) {
+ await this.ratePlanService.assertSellable(
+ dto.propertyId,
+ dto.ratePlanId,
+ dto.arrivalDate,
+ dto.departureDate,
+ db,
+ );
+ } else {
+ await this.ratePlanService.assertSellable(
+ dto.propertyId,
+ dto.ratePlanId,
+ dto.arrivalDate,
+ dto.departureDate,
+ );
+ }
+
+ // Availability check + insert run under the room-type inventory mutex in
+ // the same transaction. Under READ COMMITTED, competing canonical creates
+ // serialize on that row and the later transaction re-reads every stay date.
+ const createInTransaction = async (transaction: any) => {
+ // A room-type row is the inventory mutex. Every canonical reservation
+ // creation for this room type takes the same lock before re-reading
+ // date-level availability, preventing two requests from consuming the
+ // final room concurrently under READ COMMITTED.
+ await this.lockInventory(dto.propertyId, dto.roomTypeId, transaction);
+
// Check inventory availability inside the tx
const availability = await this.availabilityService.searchAvailability(
dto.propertyId,
dto.arrivalDate,
dto.departureDate,
dto.roomTypeId,
- tx,
+ transaction,
+ );
+ assertFullStayAvailability(
+ availability,
+ dto.roomTypeId,
+ dto.arrivalDate,
+ dto.departureDate,
);
- const roomTypeAvail = availability.find((a: any) => a.roomTypeId === dto.roomTypeId);
- if (!roomTypeAvail || roomTypeAvail.available <= 0) {
- throw new BadRequestException(
- `No availability for room type ${dto.roomTypeId} on the requested dates`,
- );
- }
- const [booking] = await tx
+ const [booking] = await transaction
.insert(bookings)
.values({
propertyId: dto.propertyId,
@@ -131,7 +178,7 @@ export class ReservationService {
})
.returning();
- const [reservation] = await tx
+ const [reservation] = await transaction
.insert(reservations)
.values({
propertyId: dto.propertyId,
@@ -144,6 +191,7 @@ export class ReservationService {
ratePlanId: dto.ratePlanId,
totalAmount: dto.totalAmount,
currencyCode: dto.currencyCode,
+ acceptedPricingSnapshot: opts?.acceptedPricingSnapshot,
adults: dto.adults ?? 1,
children: dto.children ?? 0,
specialRequests: dto.specialRequests,
@@ -152,7 +200,7 @@ export class ReservationService {
.returning();
// Named occupants roster — primary mirrors reservations.guestId.
- await tx.insert(reservationGuests).values({
+ await transaction.insert(reservationGuests).values({
propertyId: dto.propertyId,
reservationId: reservation.id,
guestId: dto.guestId,
@@ -160,25 +208,44 @@ export class ReservationService {
});
return { ...reservation, booking };
- });
+ };
+ const result = tx
+ ? await createInTransaction(tx)
+ : await this.db.transaction(createInTransaction);
// Emit reservation.created so channel manager / ARI can push updated availability.
- await this.webhookService.emit(
- 'reservation.created',
- 'reservation',
- result.id,
- {
- reservationId: result.id,
- arrivalDate: result.arrivalDate,
- departureDate: result.departureDate,
- roomTypeId: result.roomTypeId,
- },
- dto.propertyId,
- );
+ if (!tx) {
+ await this.webhookService.emit(
+ 'reservation.created',
+ 'reservation',
+ result.id,
+ {
+ reservationId: result.id,
+ arrivalDate: result.arrivalDate,
+ departureDate: result.departureDate,
+ roomTypeId: result.roomTypeId,
+ },
+ dto.propertyId,
+ );
+ }
return result;
}
+ async lockInventory(propertyId: string, roomTypeId: string, tx: any): Promise {
+ const lockedRoomTypes = await tx
+ .select({ id: roomTypes.id })
+ .from(roomTypes)
+ .where(and(
+ eq(roomTypes.id, roomTypeId),
+ eq(roomTypes.propertyId, propertyId),
+ ))
+ .for('update');
+ if (!lockedRoomTypes.some((row: { id: string }) => row.id === roomTypeId)) {
+ throw new NotFoundException(`room type ${roomTypeId} not found in this property`);
+ }
+ }
+
async confirm(id: string, propertyId: string) {
const reservation = await this.findByIdRaw(id, propertyId);
// UX: short-circuit with a clear error for callers passing stale state.
@@ -1011,6 +1078,28 @@ export class ReservationService {
async modify(id: string, propertyId: string, dto: ModifyReservationDto) {
const reservation = await this.findByIdRaw(id, propertyId);
+ // Booking Request acceptance freezes the operational tariff. Until the
+ // audited Stay Amendment workflow owns coordinated snapshot + folio
+ // changes, do not let the generic modify path make that tariff stale.
+ if (reservation.acceptedPricingSnapshot) {
+ const changesAcceptedPricing =
+ (dto.arrivalDate !== undefined && dto.arrivalDate !== reservation.arrivalDate)
+ || (dto.departureDate !== undefined && dto.departureDate !== reservation.departureDate)
+ || (dto.roomTypeId !== undefined && dto.roomTypeId !== reservation.roomTypeId)
+ || (dto.ratePlanId !== undefined && dto.ratePlanId !== reservation.ratePlanId)
+ || (
+ dto.totalAmount !== undefined
+ && !new Decimal(dto.totalAmount).equals(reservation.totalAmount)
+ )
+ || (dto.adults !== undefined && dto.adults !== reservation.adults)
+ || (dto.children !== undefined && dto.children !== reservation.children);
+ if (changesAcceptedPricing) {
+ throw new ConflictException(
+ 'A Stay Amendment is required before changing accepted pricing, stay dates, room type, rate plan, occupancy, or total',
+ );
+ }
+ }
+
// Can only modify before check-out
const nonModifiable: ReservationStatus[] = ['checked_out', 'no_show', 'cancelled'];
if (nonModifiable.includes(reservation.status as ReservationStatus)) {
@@ -1028,13 +1117,7 @@ export class ReservationService {
if (dto.arrivalDate || dto.departureDate) {
const arrival = dto.arrivalDate ?? reservation.arrivalDate;
const departure = dto.departureDate ?? reservation.departureDate;
- const nights = Math.ceil(
- (new Date(departure).getTime() - new Date(arrival).getTime()) /
- (1000 * 60 * 60 * 24),
- );
- if (nights <= 0) {
- throw new BadRequestException('Departure date must be after arrival date');
- }
+ const nights = stayDates(arrival, departure).length;
if (dto.arrivalDate) updates['arrivalDate'] = dto.arrivalDate;
if (dto.departureDate) updates['departureDate'] = dto.departureDate;
updates['nights'] = nights;
@@ -1062,17 +1145,16 @@ export class ReservationService {
// The existing reservation still occupies its old window (and room type) in searchAvailability,
// so if roomType is unchanged we must exclude it from the count to avoid blocking itself on overlap.
//
- // TOCTOU: we run the availability check and the update inside the same transaction
- // so concurrent writers cannot slip between them. Postgres' default isolation
- // (READ COMMITTED) still permits some overlap, but the race window is minimized.
- // For stricter guarantees, raise the transaction to SERIALIZABLE — not done here
- // to avoid breakage with drizzle-orm's postgres-js driver; see Bug 5.
- const updated = await this.db.transaction(async (tx: any) => {
+ // Use the same room-type inventory mutex as canonical creation so a modify
+ // cannot race another create/modify for the final unit.
+ const updated: ReservationRow = await this.db.transaction(async (tx: any) => {
if (arrivalChanged || departureChanged || roomTypeChanged) {
const newArrival = (dto.arrivalDate ?? reservation.arrivalDate) as string;
const newDeparture = (dto.departureDate ?? reservation.departureDate) as string;
const newRoomTypeId = (dto.roomTypeId ?? reservation.roomTypeId) as string;
+ await this.lockInventory(propertyId, newRoomTypeId, tx);
+
const availability = await this.availabilityService.searchAvailability(
reservation.propertyId,
newArrival,
@@ -1088,21 +1170,29 @@ export class ReservationService {
reservation.arrivalDate < newDeparture &&
reservation.departureDate > newArrival;
- const nightsOk = availability
- .filter((a: any) => a.roomTypeId === newRoomTypeId)
- .every((a: any) => {
- const existingOccupiesThisNight =
- currentCountsItself &&
- (reservation.arrivalDate as string) <= a.date &&
- (reservation.departureDate as string) > a.date;
- const effectiveAvailable = a.available + (existingOccupiesThisNight ? 1 : 0);
- return effectiveAvailable > 0;
- });
-
- if (!nightsOk) {
- throw new ConflictException(
- `No availability for room type ${newRoomTypeId} on ${newArrival} → ${newDeparture}`,
+ const adjustedAvailability = availability.map((row: any) => {
+ if (row.roomTypeId !== newRoomTypeId) return row;
+ const existingOccupiesThisNight =
+ currentCountsItself &&
+ (reservation.arrivalDate as string) <= row.date &&
+ (reservation.departureDate as string) > row.date;
+ return {
+ ...row,
+ available: row.available + (existingOccupiesThisNight ? 1 : 0),
+ };
+ });
+ try {
+ assertFullStayAvailability(
+ adjustedAvailability,
+ newRoomTypeId,
+ newArrival,
+ newDeparture,
);
+ } catch (error: unknown) {
+ if (error instanceof BadRequestException) {
+ throw new ConflictException(error.message);
+ }
+ throw error;
}
}
@@ -1133,7 +1223,66 @@ export class ReservationService {
updated.propertyId,
);
- return updated;
+ return this.amendmentResult(reservation, updated);
+ }
+
+ /**
+ * Explicit seam for a Booking Request stay amendment that already owns the
+ * property/request/reservation/inventory locks and transaction. The generic
+ * modify path intentionally cannot opt into this behavior.
+ */
+ async modifyAcceptedStay(
+ lockedReservation: ReservationRow,
+ propertyId: string,
+ dto: Required>,
+ acceptedPricingSnapshot: AcceptedPricingSnapshot,
+ tx: any,
+ ): Promise {
+ if (
+ lockedReservation.propertyId !== propertyId
+ || !lockedReservation.acceptedPricingSnapshot
+ ) {
+ throw new ConflictException('Reservation is not eligible for an accepted stay amendment');
+ }
+ const nonModifiable: ReservationStatus[] = ['checked_out', 'no_show', 'cancelled'];
+ if (nonModifiable.includes(lockedReservation.status as ReservationStatus)) {
+ throw new BadRequestException(
+ `Cannot modify reservation in '${lockedReservation.status}' status`,
+ );
+ }
+ const dates = stayDates(dto.arrivalDate, dto.departureDate);
+ if (
+ acceptedPricingSnapshot.currencyCode !== lockedReservation.currencyCode
+ || acceptedPricingSnapshot.grandTotal !== new Decimal(dto.totalAmount).toFixed(2)
+ ) {
+ throw new ConflictException('Amended pricing does not match the reservation currency and total');
+ }
+ if (
+ acceptedPricingSnapshot.nights.length !== dates.length
+ || acceptedPricingSnapshot.nights.some((night, index) => night.date !== dates[index])
+ ) {
+ throw new ConflictException('Amended pricing does not cover the complete stay window');
+ }
+
+ const [updated] = await tx
+ .update(reservations)
+ .set({
+ arrivalDate: dto.arrivalDate,
+ departureDate: dto.departureDate,
+ nights: dates.length,
+ totalAmount: acceptedPricingSnapshot.grandTotal,
+ acceptedPricingSnapshot,
+ updatedAt: new Date(),
+ })
+ .where(and(
+ eq(reservations.id, lockedReservation.id),
+ eq(reservations.propertyId, propertyId),
+ ))
+ .returning();
+ if (!updated) {
+ throw new ConflictException('Reservation changed while applying the stay amendment');
+ }
+ return this.amendmentResult(lockedReservation, updated);
}
async findById(id: string, propertyId: string) {
@@ -1325,8 +1474,9 @@ export class ReservationService {
id: string,
propertyId: string,
label: string,
+ db: any = this.db,
): Promise {
- const [row] = await this.db
+ const [row] = await db
.select({ id: table.id })
.from(table)
.where(and(eq(table.id, id), eq(table.propertyId, propertyId)));
@@ -1348,6 +1498,19 @@ export class ReservationService {
return reservation;
}
+ private amendmentResult(
+ previous: ReservationRow,
+ reservation: ReservationRow,
+ ): ReservationAmendmentResult {
+ return {
+ reservation,
+ previousArrivalDate: previous.arrivalDate,
+ previousDepartureDate: previous.departureDate,
+ previousTotalAmount: previous.totalAmount,
+ newTotalAmount: reservation.totalAmount,
+ };
+ }
+
private encryptIdNumber(plainText: string): { encrypted: string; iv: string; authTag: string } {
const key = process.env['ID_ENCRYPTION_KEY'];
if (!key) {
diff --git a/apps/api/src/modules/tax/tax.service.ts b/apps/api/src/modules/tax/tax.service.ts
index f0af2add..41fcf123 100644
--- a/apps/api/src/modules/tax/tax.service.ts
+++ b/apps/api/src/modules/tax/tax.service.ts
@@ -136,8 +136,9 @@ export class TaxService {
* Get the active tax profile for a property on a given date.
* Returns profile with its active rules, sorted by sortOrder.
*/
- async getActiveTaxProfile(propertyId: string, date: string) {
- const [profile] = await this.db
+ async getActiveTaxProfile(propertyId: string, date: string, db?: any) {
+ const conn = db ?? this.db;
+ const [profile] = await conn
.select()
.from(taxProfiles)
.where(
@@ -151,7 +152,7 @@ export class TaxService {
if (!profile) return null;
- const rules = await this.db
+ const rules = await conn
.select()
.from(taxRules)
.where(
@@ -186,14 +187,20 @@ export class TaxService {
numberOfNights?: number;
nightNumber?: number;
},
+ db?: any,
): Promise {
- const profile = await this.getActiveTaxProfile(propertyId, serviceDate.slice(0, 10));
+ const conn = db ?? this.db;
+ const profile = await this.getActiveTaxProfile(
+ propertyId,
+ serviceDate.slice(0, 10),
+ conn,
+ );
if (!profile || !profile.rules.length) return [];
// Load guest if needed for exemption checks
let guest: any = null;
if (options?.guestId) {
- const [g] = await this.db
+ const [g] = await conn
.select()
.from(guests)
.where(eq(guests.id, options.guestId));
diff --git a/apps/api/vitest.e2e.config.ts b/apps/api/vitest.e2e.config.ts
new file mode 100644
index 00000000..b7c049d5
--- /dev/null
+++ b/apps/api/vitest.e2e.config.ts
@@ -0,0 +1,16 @@
+import swc from 'unplugin-swc';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ root: './',
+ include: ['src/**/*.e2e-spec.ts'],
+ testTimeout: 30000,
+ },
+ plugins: [
+ swc.vite({
+ module: { type: 'es6' },
+ }),
+ ],
+});
diff --git a/apps/booking/src/App.tsx b/apps/booking/src/App.tsx
index 55ea9ee7..a426fd88 100644
--- a/apps/booking/src/App.tsx
+++ b/apps/booking/src/App.tsx
@@ -8,8 +8,13 @@ import { GuestDetails } from './pages/GuestDetails';
import { Payment } from './pages/Payment';
import { Confirmation } from './pages/Confirmation';
import { ManageBooking } from './pages/ManageBooking';
+import { RequestApplication } from './pages/RequestApplication';
+import { RequestPayment } from './pages/RequestPayment';
+import { RequestReceived } from './pages/RequestReceived';
+import { isBookingRequestsUiEnabled } from './lib/bookingRequestsFeature';
export default function App() {
+ const requestRoutesEnabled = isBookingRequestsUiEnabled();
return (
@@ -20,6 +25,13 @@ export default function App() {
} />
} />
} />
+ {requestRoutesEnabled && (
+ <>
+ } />
+ } />
+ } />
+ >
+ )}
} />
} />
diff --git a/apps/booking/src/api/client.ts b/apps/booking/src/api/client.ts
index 0a300f59..282d8f32 100644
--- a/apps/booking/src/api/client.ts
+++ b/apps/booking/src/api/client.ts
@@ -8,9 +8,13 @@ import type {
CancelResponse,
QuoteRequest,
QuoteResponse,
+ RequestPaymentMethodSetupRequest,
+ RequestPaymentMethodSetupResponse,
SearchRequest,
SearchResponse,
SellableServicesResponse,
+ SubmitBookingRequest,
+ BookingRequestAcknowledgement,
} from './types';
/**
@@ -69,6 +73,23 @@ export const bookingApi = {
return data;
},
+ createRequestPaymentMethodSetup: async (
+ body: RequestPaymentMethodSetupRequest,
+ ): Promise => {
+ const { data } = await api.post(
+ '/request-payment-method-setup',
+ body,
+ );
+ return data;
+ },
+
+ submitRequest: async (
+ body: SubmitBookingRequest,
+ ): Promise => {
+ const { data } = await api.post('/requests', body);
+ return data;
+ },
+
getBooking: async (confirmationNumber: string): Promise => {
const { data } = await api.get(
`/bookings/${encodeURIComponent(confirmationNumber)}`,
diff --git a/apps/booking/src/api/types.ts b/apps/booking/src/api/types.ts
index dc84a8e2..873bd0ab 100644
--- a/apps/booking/src/api/types.ts
+++ b/apps/booking/src/api/types.ts
@@ -17,6 +17,30 @@ export interface Branding {
accentColor?: string | null;
}
+export type BookingMode = 'instant' | 'request';
+export type PaymentMethodCollection = 'required' | 'optional' | 'disabled';
+export type PaymentMethodClientMode = 'mock' | 'stripe' | 'unsupported';
+export type BookingFormQuestionType =
+ | 'short_text'
+ | 'long_text'
+ | 'single_select'
+ | 'multi_select'
+ | 'yes_no'
+ | 'date';
+
+export interface BookingFormQuestion {
+ id: string;
+ label: string;
+ type: BookingFormQuestionType;
+ options?: string[];
+ order: number;
+ isActive: boolean;
+ isRequired: boolean;
+}
+
+export type BookingApplicationAnswer = string | string[] | boolean;
+export type BookingApplicationAnswers = Record;
+
export interface BookingConfig {
isEnabled: boolean;
displayName?: string | null;
@@ -27,6 +51,10 @@ export interface BookingConfig {
stripePublishableKey?: string | null;
sellableRoomTypeIds: string[];
sellableRatePlanIds: string[];
+ bookingMode: BookingMode;
+ paymentMethodCollection: PaymentMethodCollection;
+ paymentMethodClientMode?: PaymentMethodClientMode;
+ formQuestions: BookingFormQuestion[];
}
// --- Search ---
@@ -167,6 +195,47 @@ export interface BookResponse {
cancellationPolicy: string;
}
+// --- Request to book ---
+
+export interface RequestPaymentMethodSetupRequest {
+ guestEmail: string;
+ applicationId: string;
+ idempotencyKey: string;
+}
+
+export interface RequestPaymentMethodSetupResponse {
+ setupIntentId: string;
+ clientSecret: string;
+ clientMode: 'mock' | 'stripe';
+}
+
+export interface SubmitBookingRequest {
+ idempotencyKey: string;
+ roomTypeId: string;
+ ratePlanId: string;
+ checkIn: string;
+ checkOut: string;
+ guestFirstName: string;
+ guestLastName: string;
+ guestEmail: string;
+ guestPhone?: string;
+ adults: number;
+ children?: number;
+ specialRequests?: string;
+ serviceIds?: string[];
+ applicationAnswers: BookingApplicationAnswers;
+ setupIntentId?: string;
+ consentAccepted?: true;
+ consentText?: string;
+ consentVersion?: string;
+}
+
+export interface BookingRequestAcknowledgement {
+ requestId: string;
+ status: 'pending';
+ message: string;
+}
+
// --- Manage ---
export interface BookingDetails {
diff --git a/apps/booking/src/components/Button.tsx b/apps/booking/src/components/Button.tsx
index 780d79d2..a760dc60 100644
--- a/apps/booking/src/components/Button.tsx
+++ b/apps/booking/src/components/Button.tsx
@@ -6,7 +6,7 @@ export function Button({ variant = 'primary', className = '', style, ...rest }:
// Radius is themeable on every variant; the primary variant also takes its background and text
// color from the theme so it matches the host site.
const base =
- 'inline-flex items-center justify-center rounded-brand px-4 py-2 text-sm font-semibold transition disabled:opacity-50 disabled:cursor-not-allowed';
+ 'inline-flex items-center justify-center rounded-brand px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--haip-primary,#0D9488)] focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const styles = {
primary: 'hover:opacity-90',
secondary: 'border border-gray-300 text-gray-800 hover:bg-gray-50',
diff --git a/apps/booking/src/components/ConfiguredQuestion.tsx b/apps/booking/src/components/ConfiguredQuestion.tsx
new file mode 100644
index 00000000..0ecd7bf0
--- /dev/null
+++ b/apps/booking/src/components/ConfiguredQuestion.tsx
@@ -0,0 +1,165 @@
+import type {
+ BookingApplicationAnswer,
+ BookingFormQuestion,
+} from '../api/types';
+import { Field, inputClass, RequiredIndicator } from './Field';
+
+interface ConfiguredQuestionProps {
+ question: BookingFormQuestion;
+ value?: BookingApplicationAnswer;
+ onChange: (value?: BookingApplicationAnswer) => void;
+ disabled?: boolean;
+ invalid?: boolean;
+ errorId?: string;
+}
+
+export function ConfiguredQuestion({
+ question,
+ value,
+ onChange,
+ disabled,
+ invalid,
+ errorId,
+}: ConfiguredQuestionProps) {
+ const id = `request-question-${question.id}`;
+ const textValue = typeof value === 'string' ? value : '';
+ const errorProps = {
+ 'aria-invalid': invalid || undefined,
+ 'aria-describedby': invalid ? errorId : undefined,
+ } as const;
+
+ if (question.type === 'long_text') {
+ return (
+
+
+ );
+ }
+
+ if (question.type === 'single_select') {
+ return (
+
+ onChange(event.target.value || undefined)}
+ >
+ Select an option
+ {(question.options ?? []).map((option) => (
+
+ {option}
+
+ ))}
+
+
+ );
+ }
+
+ if (question.type === 'multi_select') {
+ const selected = Array.isArray(value) ? value : [];
+ const selectedOptions = new Set(selected);
+ return (
+
+
+ {question.label}
+ {question.isRequired && }
+
+
+ {(question.options ?? []).map((option, index) => (
+
+ {
+ const next = event.target.checked
+ ? [...selected, option]
+ : selected.filter((item) => item !== option);
+ onChange(next.length > 0 ? next : undefined);
+ }}
+ />
+ {option}
+
+ ))}
+
+
+ );
+ }
+
+ if (question.type === 'yes_no') {
+ return (
+
+
+ {question.label}
+ {question.isRequired && }
+
+
+ {[true, false].map((answer, index) => (
+
+ onChange(answer)}
+ />
+ {answer ? 'Yes' : 'No'}
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+ onChange(event.target.value || undefined)}
+ />
+
+ );
+}
diff --git a/apps/booking/src/components/Field.tsx b/apps/booking/src/components/Field.tsx
index c02b6373..16209e0b 100644
--- a/apps/booking/src/components/Field.tsx
+++ b/apps/booking/src/components/Field.tsx
@@ -10,12 +10,21 @@ export function Field({ label, htmlFor, children, required }: FieldProps) {
{label}
- {required && * }
+ {required && }
{children}
);
}
+export function RequiredIndicator() {
+ return (
+ <>
+ *
+ required
+ >
+ );
+}
+
export const inputClass =
- 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-gray-500 focus:outline-none focus:ring-1 focus:ring-gray-400';
+ 'w-full rounded-brand border border-[#D0D5DD] px-3 py-2 text-sm focus-visible:border-[var(--haip-primary,#0D9488)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--haip-primary,#0D9488)]/30';
diff --git a/apps/booking/src/components/Layout.tsx b/apps/booking/src/components/Layout.tsx
index 0012bce3..e5b2d71f 100644
--- a/apps/booking/src/components/Layout.tsx
+++ b/apps/booking/src/components/Layout.tsx
@@ -1,10 +1,12 @@
-import { Link } from 'react-router-dom';
+import { Link, useLocation } from 'react-router-dom';
import { useConfig } from '../context/ConfigContext';
import { useBookingFlow } from '../context/BookingFlowContext';
export function Layout({ children }: { children: React.ReactNode }) {
const { config } = useConfig();
- const { branding } = useBookingFlow();
+ const { branding, requestSubmissionStatus, reset } = useBookingFlow();
+ const { pathname } = useLocation();
+ const isRequestFlow = pathname.startsWith('/request/');
const displayName =
branding?.displayName ?? config?.displayName ?? 'Book your stay';
@@ -14,21 +16,35 @@ export function Layout({ children }: { children: React.ReactNode }) {
className="border-b border-gray-200 bg-white"
style={{ borderTopWidth: 4, borderTopColor: 'var(--haip-primary, #06bdb4)' }}
>
-
-
- {displayName}
-
+
{
+ if (requestSubmissionStatus === 'pending') {
+ event.preventDefault();
+ return;
+ }
+ if (pathname === '/request/received') reset();
+ }}
>
- Manage booking
+ {displayName}
+ {!isRequestFlow && (
+
+ Manage booking
+
+ )}
-
{children}
-
+ {children}
+
Commission-free direct booking · Powered by HAIP
diff --git a/apps/booking/src/components/MockSetupForm.tsx b/apps/booking/src/components/MockSetupForm.tsx
new file mode 100644
index 00000000..18105871
--- /dev/null
+++ b/apps/booking/src/components/MockSetupForm.tsx
@@ -0,0 +1,50 @@
+import { useState } from 'react';
+import { Button } from './Button';
+import { requestCardConsent } from '../lib/requestCardConsent';
+
+export function MockSetupForm({
+ propertyName,
+ setupIntentId,
+ submitting,
+ onConfirmed,
+}: {
+ propertyName: string;
+ setupIntentId: string;
+ submitting: boolean;
+ onConfirmed: (setupIntentId: string, consentText: string) => void;
+}) {
+ const [consentAccepted, setConsentAccepted] = useState(false);
+ const consentText = requestCardConsent(propertyName);
+
+ return (
+
+
+
Local payment simulation
+
+ Development mode will save a simulated Visa ending in 4242. No card
+ details are collected and no payment is made.
+
+
+
+ setConsentAccepted(event.target.checked)}
+ />
+ {consentText}
+
+
onConfirmed(setupIntentId, consentText)}
+ >
+ {submitting
+ ? 'Submitting request…'
+ : 'Save test card and submit booking request'}
+
+
+ );
+}
diff --git a/apps/booking/src/components/RequestStayDocket.tsx b/apps/booking/src/components/RequestStayDocket.tsx
new file mode 100644
index 00000000..70aa771d
--- /dev/null
+++ b/apps/booking/src/components/RequestStayDocket.tsx
@@ -0,0 +1,101 @@
+import { useBookingFlow } from '../context/BookingFlowContext';
+import { calendarDate, money } from '../lib/format';
+
+const REQUEST_STEPS = ['Stay', 'Your details', 'Payment details'];
+
+export function RequestSteps({ active }: { active: 2 | 3 }) {
+ return (
+
+
+ {REQUEST_STEPS.map((step, index) => {
+ const number = index + 1;
+ const current = number === active;
+ const complete = number < active;
+ return (
+
+ {number}.
+ {step}
+
+ );
+ })}
+
+
+ );
+}
+
+export function RequestStayDocket() {
+ const { criteria, roomType, quote } = useBookingFlow();
+ if (!criteria || !roomType || !quote) return null;
+
+ return (
+
+ Your request
+
+
+ {roomType.roomTypeName ?? roomType.name ?? 'Selected room'}
+
+
+ {money(quote.grandTotal, quote.currencyCode)}
+
+
+ {calendarDate(criteria.checkIn)}–{calendarDate(criteria.checkOut)} ·{' '}
+ {quote.nights} night
+ {quote.nights === 1 ? '' : 's'}
+
+
+
+
+
+ Room
+ {money(quote.roomTotal, quote.currencyCode)}
+
+
+ Taxes & fees
+ {money(quote.taxTotal, quote.currencyCode)}
+
+
+ Quoted total
+ {money(quote.grandTotal, quote.currencyCode)}
+
+
+
+
+
Request only
+
+ The hotel reviews your request and confirms the final price by email.
+
+
+
+ );
+}
+
+export function RequestFlowFrame({
+ active,
+ children,
+}: {
+ active: 2 | 3;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/booking/src/components/StripeSetupForm.tsx b/apps/booking/src/components/StripeSetupForm.tsx
new file mode 100644
index 00000000..6e2aa98f
--- /dev/null
+++ b/apps/booking/src/components/StripeSetupForm.tsx
@@ -0,0 +1,109 @@
+import { useEffect, useRef, useState } from 'react';
+import {
+ PaymentElement,
+ useElements,
+ useStripe,
+} from '@stripe/react-stripe-js';
+import { Button } from './Button';
+import { requestCardConsent } from '../lib/requestCardConsent';
+
+export function StripeSetupForm({
+ propertyName,
+ submitting,
+ onConfirmed,
+ onSkip,
+}: {
+ propertyName: string;
+ submitting: boolean;
+ onConfirmed: (setupIntentId: string, consentText: string) => void;
+ onSkip?: () => void;
+}) {
+ const stripe = useStripe();
+ const elements = useElements();
+ const [consentAccepted, setConsentAccepted] = useState(false);
+ const [confirming, setConfirming] = useState(false);
+ const [error, setError] = useState();
+ const mounted = useRef(true);
+ const consentText = requestCardConsent(propertyName);
+
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!stripe || !elements || !consentAccepted || confirming || submitting) return;
+
+ setConfirming(true);
+ setError(undefined);
+ const result = await stripe.confirmSetup({
+ elements,
+ redirect: 'if_required',
+ });
+ if (!mounted.current) return;
+ setConfirming(false);
+
+ if (result.error) {
+ setError(result.error.message ?? 'The payment method could not be saved.');
+ return;
+ }
+ if (!result.setupIntent || result.setupIntent.status !== 'succeeded') {
+ setError('The payment method setup did not complete. Please try again.');
+ return;
+ }
+ onConfirmed(result.setupIntent.id, consentText);
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/booking/src/context/BookingFlowContext.tsx b/apps/booking/src/context/BookingFlowContext.tsx
index 208ae3af..9e1d6f8e 100644
--- a/apps/booking/src/context/BookingFlowContext.tsx
+++ b/apps/booking/src/context/BookingFlowContext.tsx
@@ -1,10 +1,27 @@
-import { createContext, useContext, useMemo, useState } from 'react';
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useReducer,
+ useRef,
+} from 'react';
+import {
+ UNSAFE_DataRouterContext,
+ useBlocker,
+ useLocation,
+ useNavigate,
+} from 'react-router-dom';
import type {
+ BookingApplicationAnswers,
+ BookingRequestAcknowledgement,
Branding,
QuoteResponse,
SearchRate,
SearchRoomType,
+ SubmitBookingRequest,
} from '../api/types';
+import { bookingApi } from '../api/client';
/** Guest-entered search criteria + the selections built up across the flow. */
export interface SearchCriteria {
@@ -43,53 +60,202 @@ interface BookingFlowState {
guest?: GuestInfo;
setGuest: (g: GuestInfo) => void;
+ applicationAnswers: BookingApplicationAnswers;
+ setApplicationAnswers: (answers: BookingApplicationAnswers) => void;
+
+ setupIntentId?: string;
+ setSetupIntentId: (id?: string) => void;
+
+ setupIntentConsentText?: string;
+ setSetupIntentConsentText: (text?: string) => void;
+
+ requestAcknowledgement?: BookingRequestAcknowledgement;
+ setRequestAcknowledgement: (ack?: BookingRequestAcknowledgement) => void;
+
+ requestIdempotencyKey?: string;
+ ensureRequestIdempotencyKey: () => string;
+
+ requestPaymentSetupKey?: string;
+ ensureRequestPaymentSetupKey: () => string;
+ rotateRequestPaymentSetupKey: () => string;
+
+ requestSubmissionStatus: 'idle' | 'pending' | 'success' | 'error';
+ requestSubmissionError?: unknown;
+ submitRequest: (
+ request: SubmitBookingRequest,
+ ) => Promise;
+
reset: () => void;
}
+interface BookingFlowData {
+ criteria?: SearchCriteria;
+ branding?: Branding;
+ roomType?: SearchRoomType;
+ rate?: SearchRate;
+ serviceIds: string[];
+ quote?: QuoteResponse;
+ guest?: GuestInfo;
+ applicationAnswers: BookingApplicationAnswers;
+ setupIntentId?: string;
+ setupIntentConsentText?: string;
+ requestAcknowledgement?: BookingRequestAcknowledgement;
+ requestIdempotencyKey?: string;
+ requestPaymentSetupKey?: string;
+ requestSubmissionStatus: 'idle' | 'pending' | 'success' | 'error';
+ requestSubmissionError?: unknown;
+}
+
+type BookingFlowAction =
+ | { type: 'patch'; value: Partial }
+ | { type: 'selection'; roomType: SearchRoomType; rate: SearchRate }
+ | { type: 'reset' };
+
+const initialFlow: BookingFlowData = {
+ serviceIds: [],
+ applicationAnswers: {},
+ requestSubmissionStatus: 'idle',
+};
+
+function bookingFlowReducer(
+ state: BookingFlowData,
+ action: BookingFlowAction,
+): BookingFlowData {
+ switch (action.type) {
+ case 'patch': {
+ const entries = Object.entries(action.value) as Array<
+ [keyof BookingFlowData, BookingFlowData[keyof BookingFlowData]]
+ >;
+ if (entries.every(([key, value]) => Object.is(state[key], value))) {
+ return state;
+ }
+ return { ...state, ...action.value };
+ }
+ case 'selection':
+ return {
+ ...state,
+ roomType: action.roomType,
+ rate: action.rate,
+ serviceIds: [],
+ };
+ case 'reset':
+ return {
+ criteria: state.criteria,
+ branding: state.branding,
+ serviceIds: [],
+ applicationAnswers: {},
+ requestSubmissionStatus: 'idle',
+ };
+ }
+}
+
const BookingFlowContext = createContext(null);
export function BookingFlowProvider({ children }: { children: React.ReactNode }) {
- const [criteria, setCriteria] = useState();
- const [branding, setBranding] = useState();
- const [roomType, setRoomType] = useState();
- const [rate, setRate] = useState();
- const [serviceIds, setServiceIds] = useState([]);
- const [quote, setQuote] = useState();
- const [guest, setGuest] = useState();
+ const [state, dispatch] = useReducer(bookingFlowReducer, initialFlow);
+ const requestKey = useRef();
+ const requestPaymentSetupKey = useRef();
+ const requestSubmission = useRef>();
const value = useMemo(
() => ({
- criteria,
- setCriteria,
- branding,
- setBranding,
- roomType,
- rate,
- setSelection: (rt, r) => {
- setRoomType(rt);
- setRate(r);
- setServiceIds([]);
+ ...state,
+ setCriteria: (criteria) => dispatch({ type: 'patch', value: { criteria } }),
+ setBranding: (branding) => dispatch({ type: 'patch', value: { branding } }),
+ setSelection: (roomType, rate) =>
+ dispatch({ type: 'selection', roomType, rate }),
+ setServiceIds: (serviceIds) =>
+ dispatch({ type: 'patch', value: { serviceIds } }),
+ setQuote: (quote) => dispatch({ type: 'patch', value: { quote } }),
+ setGuest: (guest) => dispatch({ type: 'patch', value: { guest } }),
+ setApplicationAnswers: (applicationAnswers) =>
+ dispatch({ type: 'patch', value: { applicationAnswers } }),
+ setSetupIntentId: (setupIntentId) =>
+ dispatch({ type: 'patch', value: { setupIntentId } }),
+ setSetupIntentConsentText: (setupIntentConsentText) =>
+ dispatch({ type: 'patch', value: { setupIntentConsentText } }),
+ setRequestAcknowledgement: (requestAcknowledgement) =>
+ dispatch({ type: 'patch', value: { requestAcknowledgement } }),
+ ensureRequestIdempotencyKey: () => {
+ if (state.requestIdempotencyKey) return state.requestIdempotencyKey;
+ if (requestKey.current) return requestKey.current;
+ const id = `booking-widget-${crypto.randomUUID()}`;
+ requestKey.current = id;
+ dispatch({
+ type: 'patch',
+ value: { requestIdempotencyKey: id },
+ });
+ return id;
+ },
+ ensureRequestPaymentSetupKey: () => {
+ if (state.requestPaymentSetupKey) return state.requestPaymentSetupKey;
+ if (requestPaymentSetupKey.current) return requestPaymentSetupKey.current;
+ const id = `booking-widget-card-${crypto.randomUUID()}`;
+ requestPaymentSetupKey.current = id;
+ dispatch({ type: 'patch', value: { requestPaymentSetupKey: id } });
+ return id;
+ },
+ rotateRequestPaymentSetupKey: () => {
+ const id = `booking-widget-card-${crypto.randomUUID()}`;
+ requestPaymentSetupKey.current = id;
+ dispatch({ type: 'patch', value: { requestPaymentSetupKey: id } });
+ return id;
+ },
+ submitRequest: (request) => {
+ if (requestSubmission.current) return requestSubmission.current;
+
+ dispatch({
+ type: 'patch',
+ value: {
+ requestAcknowledgement: undefined,
+ requestSubmissionStatus: 'pending',
+ requestSubmissionError: undefined,
+ },
+ });
+ const pendingRequest = bookingApi
+ .submitRequest(request)
+ .then((requestAcknowledgement) => {
+ dispatch({
+ type: 'patch',
+ value: {
+ requestAcknowledgement,
+ requestSubmissionStatus: 'success',
+ },
+ });
+ return requestAcknowledgement;
+ })
+ .catch((requestSubmissionError: unknown) => {
+ dispatch({
+ type: 'patch',
+ value: {
+ requestSubmissionStatus: 'error',
+ requestSubmissionError,
+ },
+ });
+ throw requestSubmissionError;
+ })
+ .finally(() => {
+ if (requestSubmission.current === pendingRequest) {
+ requestSubmission.current = undefined;
+ }
+ });
+ requestSubmission.current = pendingRequest;
+ return pendingRequest;
},
- serviceIds,
- setServiceIds,
- quote,
- setQuote,
- guest,
- setGuest,
reset: () => {
- setRoomType(undefined);
- setRate(undefined);
- setServiceIds([]);
- setQuote(undefined);
- setGuest(undefined);
+ requestKey.current = undefined;
+ requestPaymentSetupKey.current = undefined;
+ requestSubmission.current = undefined;
+ dispatch({ type: 'reset' });
},
}),
- [criteria, branding, roomType, rate, serviceIds, quote, guest],
+ [state],
);
return (
{children}
+
);
}
@@ -99,3 +265,62 @@ export function useBookingFlow(): BookingFlowState {
if (!ctx) throw new Error('useBookingFlow must be used within BookingFlowProvider');
return ctx;
}
+
+function RequestDataRouterBlocker({ isPending }: { isPending: boolean }) {
+ const blocker = useBlocker(isPending);
+
+ useEffect(() => {
+ if (!isPending && blocker.state === 'blocked') blocker.reset();
+ }, [blocker, isPending]);
+
+ return null;
+}
+
+function RequestSubmissionEffects() {
+ const {
+ requestAcknowledgement,
+ requestSubmissionStatus,
+ } = useBookingFlow();
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+ const dataRouterContext = useContext(UNSAFE_DataRouterContext);
+ const isPending = requestSubmissionStatus === 'pending';
+ const redirectedRequestId = useRef();
+
+ useEffect(() => {
+ if (!isPending) return;
+ const preventUnload = (event: BeforeUnloadEvent) => {
+ event.preventDefault();
+ event.returnValue = '';
+ };
+ window.addEventListener('beforeunload', preventUnload);
+ return () => window.removeEventListener('beforeunload', preventUnload);
+ }, [isPending]);
+
+ useEffect(() => {
+ if (requestSubmissionStatus === 'idle') {
+ redirectedRequestId.current = undefined;
+ return;
+ }
+ if (
+ requestSubmissionStatus !== 'success' ||
+ !requestAcknowledgement ||
+ redirectedRequestId.current === requestAcknowledgement.requestId
+ ) {
+ return;
+ }
+ redirectedRequestId.current = requestAcknowledgement.requestId;
+ if (pathname !== '/request/received') {
+ navigate('/request/received', { replace: true });
+ }
+ }, [
+ navigate,
+ pathname,
+ requestAcknowledgement,
+ requestSubmissionStatus,
+ ]);
+
+ return dataRouterContext ? (
+
+ ) : null;
+}
diff --git a/apps/booking/src/index.css b/apps/booking/src/index.css
index 2f659b80..66068bc0 100644
--- a/apps/booking/src/index.css
+++ b/apps/booking/src/index.css
@@ -27,3 +27,14 @@
color: var(--haip-text);
line-height: 1.5;
}
+
+@media (prefers-reduced-motion: reduce) {
+ .haip-booking *,
+ .haip-booking *::before,
+ .haip-booking *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}
diff --git a/apps/booking/src/lib/bookingRequestsFeature.ts b/apps/booking/src/lib/bookingRequestsFeature.ts
new file mode 100644
index 00000000..57e67dad
--- /dev/null
+++ b/apps/booking/src/lib/bookingRequestsFeature.ts
@@ -0,0 +1,4 @@
+/** Mirrors server HAIP_BOOKING_REQUESTS for booking widget UI gating. */
+export function isBookingRequestsUiEnabled(): boolean {
+ return import.meta.env.VITE_HAIP_BOOKING_REQUESTS === 'true';
+}
diff --git a/apps/booking/src/lib/format.test.ts b/apps/booking/src/lib/format.test.ts
new file mode 100644
index 00000000..242eca30
--- /dev/null
+++ b/apps/booking/src/lib/format.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest';
+import { calendarDate } from './format';
+
+describe('calendarDate', () => {
+ it('localizes an ISO calendar date without applying a local timezone shift', () => {
+ expect(calendarDate('2026-09-10', 'en-US')).toBe('Sep 10, 2026');
+ expect(calendarDate('2026-01-01', 'en-GB')).toBe('1 Jan 2026');
+ });
+
+ it('falls back to the source value when it is not a valid calendar date', () => {
+ expect(calendarDate('2026-02-30', 'en-US')).toBe('2026-02-30');
+ expect(calendarDate('not-a-date', 'en-US')).toBe('not-a-date');
+ });
+});
diff --git a/apps/booking/src/lib/format.ts b/apps/booking/src/lib/format.ts
index 938411ac..0c3a6fe9 100644
--- a/apps/booking/src/lib/format.ts
+++ b/apps/booking/src/lib/format.ts
@@ -12,6 +12,40 @@ export function money(amount: number | string, currency = 'USD'): string {
}
}
+/** Format a YYYY-MM-DD calendar date without allowing the device timezone to shift it. */
+export function calendarDate(
+ value: string,
+ locales?: string | string[],
+): string {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
+ if (!match) return value;
+
+ const year = Number(match[1]);
+ const month = Number(match[2]);
+ const day = Number(match[3]);
+ const date = new Date(0);
+ date.setUTCHours(0, 0, 0, 0);
+ date.setUTCFullYear(year, month - 1, day);
+ if (
+ date.getUTCFullYear() !== year ||
+ date.getUTCMonth() !== month - 1 ||
+ date.getUTCDate() !== day
+ ) {
+ return value;
+ }
+
+ try {
+ return new Intl.DateTimeFormat(locales, {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ timeZone: 'UTC',
+ }).format(date);
+ } catch {
+ return value;
+ }
+}
+
/** Lowest nightly/total rate across a room type's rate options. */
export function lowestRate(rates?: { totalAmount: number }[]): number | undefined {
if (!rates || rates.length === 0) return undefined;
diff --git a/apps/booking/src/lib/requestCardConsent.ts b/apps/booking/src/lib/requestCardConsent.ts
new file mode 100644
index 00000000..b7655537
--- /dev/null
+++ b/apps/booking/src/lib/requestCardConsent.ts
@@ -0,0 +1,5 @@
+export const REQUEST_CARD_CONSENT_VERSION = 'request-card-v1';
+
+export function requestCardConsent(propertyName: string): string {
+ return `I authorize ${propertyName} to securely save this payment method and charge amounts explicitly recorded against this stay. I understand no charge is made when submitting.`;
+}
diff --git a/apps/booking/src/lib/requestPayload.ts b/apps/booking/src/lib/requestPayload.ts
new file mode 100644
index 00000000..6b425bf8
--- /dev/null
+++ b/apps/booking/src/lib/requestPayload.ts
@@ -0,0 +1,53 @@
+import type { SubmitBookingRequest } from '../api/types';
+import type { GuestInfo, SearchCriteria } from '../context/BookingFlowContext';
+import type {
+ BookingApplicationAnswers,
+ SearchRate,
+ SearchRoomType,
+} from '../api/types';
+
+export interface RequestPayloadState {
+ idempotencyKey: string;
+ criteria: SearchCriteria;
+ roomType: SearchRoomType;
+ rate: SearchRate;
+ guest: GuestInfo;
+ serviceIds: string[];
+ applicationAnswers: BookingApplicationAnswers;
+}
+
+export function requestPayload(
+ state: RequestPayloadState,
+ card?: {
+ setupIntentId: string;
+ consentText: string;
+ consentVersion: string;
+ },
+): SubmitBookingRequest {
+ return {
+ idempotencyKey: state.idempotencyKey,
+ roomTypeId: state.roomType.roomTypeId,
+ ratePlanId: state.rate.ratePlanId,
+ checkIn: state.criteria.checkIn,
+ checkOut: state.criteria.checkOut,
+ adults: state.criteria.adults,
+ children: state.criteria.children,
+ guestFirstName: state.guest.firstName,
+ guestLastName: state.guest.lastName,
+ guestEmail: state.guest.email,
+ ...(state.guest.phone ? { guestPhone: state.guest.phone } : {}),
+ ...(state.guest.specialRequests
+ ? { specialRequests: state.guest.specialRequests }
+ : {}),
+ ...(state.serviceIds.length > 0 ? { serviceIds: state.serviceIds } : {}),
+ applicationAnswers: state.applicationAnswers,
+ ...(card
+ ? {
+ setupIntentId: card.setupIntentId,
+ consentAccepted: true as const,
+ consentText: card.consentText,
+ consentVersion: card.consentVersion,
+ }
+ : {}),
+ };
+}
diff --git a/apps/booking/src/mount.tsx b/apps/booking/src/mount.tsx
index ff7da944..774a519b 100644
--- a/apps/booking/src/mount.tsx
+++ b/apps/booking/src/mount.tsx
@@ -1,6 +1,6 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
-import { MemoryRouter } from 'react-router-dom';
+import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './lib/queryClient';
import { setBookingKey } from './api/client';
@@ -11,6 +11,17 @@ import { BookingFlowProvider } from './context/BookingFlowContext';
import App from './App';
import './index.css';
+function BookingWidgetError() {
+ return (
+
+
+ The booking form could not be displayed. Please refresh the page or contact
+ the hotel.
+
+
+ );
+}
+
/**
* Mount the booking widget into a host element. Shared by the standalone SPA
* (main.tsx) and the embed script (embed.ts).
@@ -30,9 +41,10 @@ export function mountBooking(el: Element) {
// (set on :root) because the container is a closer ancestor of the widget's elements.
applyTheme(el, resolveTheme(el));
- createRoot(el).render(
-
-
+ const router = createMemoryRouter([
+ {
+ path: '*',
+ element: (
@@ -40,7 +52,14 @@ export function mountBooking(el: Element) {
-
+ ),
+ errorElement: ,
+ },
+ ]);
+
+ createRoot(el).render(
+
+
,
);
}
diff --git a/apps/booking/src/pages/Extras.tsx b/apps/booking/src/pages/Extras.tsx
index 5a0dcffe..891c995a 100644
--- a/apps/booking/src/pages/Extras.tsx
+++ b/apps/booking/src/pages/Extras.tsx
@@ -7,6 +7,7 @@ import { PriceBreakdown } from '../components/PriceBreakdown';
import { useBookingFlow } from '../context/BookingFlowContext';
import { money } from '../lib/format';
import type { SellableService } from '../api/types';
+import { useConfig } from '../context/ConfigContext';
function postingLabel(rule: string, nights: number): string {
switch (rule) {
@@ -21,6 +22,7 @@ function postingLabel(rule: string, nights: number): string {
export function Extras() {
const navigate = useNavigate();
+ const { config } = useConfig();
const {
criteria,
roomType,
@@ -140,10 +142,14 @@ export function Extras() {
navigate('/guest')}
+ onClick={() =>
+ navigate(config?.bookingMode === 'request' ? '/request/application' : '/guest')
+ }
disabled={quoteMutation.isPending}
>
- Continue to guest details
+ {config?.bookingMode === 'request'
+ ? 'Continue to your details'
+ : 'Continue to guest details'}
);
diff --git a/apps/booking/src/pages/RequestApplication.test.tsx b/apps/booking/src/pages/RequestApplication.test.tsx
new file mode 100644
index 00000000..93d8a989
--- /dev/null
+++ b/apps/booking/src/pages/RequestApplication.test.tsx
@@ -0,0 +1,740 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import {
+ createMemoryRouter,
+ MemoryRouter,
+ Outlet,
+ Route,
+ RouterProvider,
+ Routes,
+ useNavigate,
+} from 'react-router-dom';
+import type {
+ BookingConfig,
+ BookingFormQuestion,
+ QuoteResponse,
+ SearchRate,
+ SearchRoomType,
+} from '../api/types';
+import { Layout } from '../components/Layout';
+import {
+ BookingFlowProvider,
+ useBookingFlow,
+} from '../context/BookingFlowContext';
+import { ConfigProvider } from '../context/ConfigContext';
+import { GuestDetails } from './GuestDetails';
+import { Payment } from './Payment';
+import { Confirmation } from './Confirmation';
+import { RequestApplication } from './RequestApplication';
+import { RequestPayment } from './RequestPayment';
+import { RequestReceived } from './RequestReceived';
+
+const api = vi.hoisted(() => ({
+ config: vi.fn(),
+ createRequestPaymentMethodSetup: vi.fn(),
+ submitRequest: vi.fn(),
+ book: vi.fn(),
+}));
+
+vi.mock('../api/client', () => ({
+ bookingApi: api,
+ errorMessage: (error: unknown) =>
+ error instanceof Error ? error.message : 'Something went wrong',
+}));
+
+const ROOM_TYPE_ID = '11111111-1111-4111-8111-111111111111';
+const RATE_PLAN_ID = '22222222-2222-4222-8222-222222222222';
+
+const questions: BookingFormQuestion[] = [
+ {
+ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
+ label: 'Expected arrival time',
+ type: 'short_text',
+ order: 0,
+ isActive: true,
+ isRequired: true,
+ },
+ {
+ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
+ label: 'Tell us about your stay',
+ type: 'long_text',
+ order: 1,
+ isActive: true,
+ isRequired: false,
+ },
+ {
+ id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
+ label: 'Purpose of stay',
+ type: 'single_select',
+ options: ['Leisure', 'Business'],
+ order: 2,
+ isActive: true,
+ isRequired: true,
+ },
+ {
+ id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
+ label: 'Interested experiences',
+ type: 'multi_select',
+ options: ['Spa', 'Dining'],
+ order: 3,
+ isActive: true,
+ isRequired: false,
+ },
+ {
+ id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
+ label: 'Travelling with a pet',
+ type: 'yes_no',
+ order: 4,
+ isActive: true,
+ isRequired: true,
+ },
+ {
+ id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
+ label: 'Celebration date',
+ type: 'date',
+ order: 5,
+ isActive: true,
+ isRequired: false,
+ },
+];
+
+const quote: QuoteResponse = {
+ nights: 2,
+ currencyCode: 'EUR',
+ lineItems: [
+ { date: '2026-09-10', rate: '290.00', tax: '30.00' },
+ { date: '2026-09-11', rate: '290.00', tax: '30.00' },
+ ],
+ roomTotal: '580.00',
+ taxTotal: '60.00',
+ grandTotal: '640.00',
+ depositPolicy: { type: 'none', refundable: true },
+ depositDue: '0.00',
+};
+
+const roomType: SearchRoomType = {
+ roomTypeId: ROOM_TYPE_ID,
+ roomTypeName: 'Deluxe room',
+};
+
+const rate: SearchRate = {
+ ratePlanId: RATE_PLAN_ID,
+ ratePlanName: 'Flexible rate',
+ totalAmount: 640,
+ currencyCode: 'EUR',
+};
+
+function requestConfig(
+ paymentMethodCollection: BookingConfig['paymentMethodCollection'] = 'required',
+ formQuestions = questions,
+): BookingConfig {
+ return {
+ isEnabled: true,
+ displayName: 'Hotel Mirador',
+ primaryColor: '#0D9488',
+ accentColor: '#183153',
+ depositPolicy: { type: 'none', refundable: true },
+ stripePublishableKey: 'pk_test_public',
+ sellableRoomTypeIds: [ROOM_TYPE_ID],
+ sellableRatePlanIds: [RATE_PLAN_ID],
+ bookingMode: 'request',
+ paymentMethodCollection,
+ formQuestions,
+ };
+}
+
+function SeedFlow({
+ target,
+ withStoredApplication = false,
+ withStoredPayment = false,
+}: {
+ target: string;
+ withStoredApplication?: boolean;
+ withStoredPayment?: boolean;
+}) {
+ const navigate = useNavigate();
+ const flow = useBookingFlow();
+
+ return (
+ {
+ flow.setCriteria({
+ checkIn: '2026-09-10',
+ checkOut: '2026-09-12',
+ adults: 2,
+ children: 0,
+ });
+ flow.setSelection(roomType, rate);
+ flow.setQuote(quote);
+ if (withStoredApplication) {
+ flow.setGuest({
+ firstName: 'Stored',
+ lastName: 'Guest',
+ email: 'stored@example.com',
+ });
+ flow.setApplicationAnswers({
+ [questions[0]!.id]: 'Stored answer',
+ [questions[2]!.id]: 'Leisure',
+ [questions[4]!.id]: false,
+ });
+ }
+ if (withStoredPayment) {
+ flow.setSetupIntentId('seti_previous_request');
+ flow.setSetupIntentConsentText('Previous request consent');
+ }
+ navigate(target);
+ }}
+ >
+ Begin test flow
+
+ );
+}
+
+function StoredState() {
+ const { guest, applicationAnswers } = useBookingFlow();
+ return {JSON.stringify({ guest, applicationAnswers })} ;
+}
+
+function FlowStateProbe() {
+ const flow = useBookingFlow();
+ return (
+
+ {JSON.stringify({
+ requestAcknowledgement: flow.requestAcknowledgement ?? null,
+ requestSubmissionStatus: flow.requestSubmissionStatus,
+ guest: flow.guest ?? null,
+ applicationAnswers: flow.applicationAnswers,
+ setupIntentId: flow.setupIntentId ?? null,
+ setupIntentConsentText: flow.setupIntentConsentText ?? null,
+ requestIdempotencyKey: flow.requestIdempotencyKey ?? null,
+ })}
+
+ );
+}
+
+function ApplicationWithNavigationAttempt() {
+ const navigate = useNavigate();
+
+ return (
+
+ navigate('/extras')}>Attempt to leave
+
+
+ );
+}
+
+function renderGuardedRequestApplication(config: BookingConfig) {
+ // React Router creates browser-like requests in its in-memory data router.
+ // A small browser Request stand-in avoids undici/jsdom AbortSignal branding.
+ class MemoryRouterRequest {
+ readonly url: string;
+ readonly method: string;
+ readonly signal?: AbortSignal | null;
+
+ constructor(input: RequestInfo | URL, init: RequestInit = {}) {
+ this.url = String(input);
+ this.method = init.method ?? 'GET';
+ this.signal = init.signal;
+ }
+ }
+ vi.stubGlobal('Request', MemoryRouterRequest);
+ api.config.mockResolvedValue(config);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ const router = createMemoryRouter([
+ {
+ element: (
+
+
+
+ ),
+ children: [
+ {
+ path: '/',
+ element: ,
+ },
+ {
+ path: '/request/application',
+ element: ,
+ },
+ {
+ path: '/request/received',
+ element: ,
+ },
+ {
+ path: '/extras',
+ element: Navigation escaped the request
,
+ },
+ ],
+ },
+ ]);
+
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+function renderRequestApplication(
+ config = requestConfig(),
+ options: {
+ withStoredApplication?: boolean;
+ withStoredPayment?: boolean;
+ receiptInLayout?: boolean;
+ flowStateProbe?: boolean;
+ } = {},
+) {
+ api.config.mockResolvedValue(config);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+
+ return render(
+
+
+
+
+
+
+
+ {options.flowStateProbe && }
+ >
+ }
+ />
+ } />
+ } />
+ } />
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+
+ ,
+ );
+}
+
+async function begin() {
+ await userEvent.click(screen.getByRole('button', { name: 'Begin test flow' }));
+ await screen.findByRole('heading', { name: 'Tell us about your stay' });
+}
+
+async function fillCoreGuest() {
+ await userEvent.type(screen.getByLabelText(/^First name/), 'Ada');
+ await userEvent.type(screen.getByLabelText(/^Last name/), 'Lovelace');
+ await userEvent.type(screen.getByLabelText(/^Email/), 'ada@example.com');
+}
+
+async function submitDisabledRequest() {
+ await begin();
+ await fillCoreGuest();
+ await userEvent.type(screen.getByLabelText(/Expected arrival time/), '18:00');
+ await userEvent.click(screen.getByRole('button', { name: 'Submit booking request' }));
+ await screen.findByText('Request received · Pending review');
+}
+
+describe('RequestApplication', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ api.submitRequest.mockResolvedValue({
+ requestId: 'request-123',
+ status: 'pending',
+ message: 'Your booking request has been received and is pending review.',
+ });
+ });
+
+ it('renders the six configured question types as accessible controls', async () => {
+ renderRequestApplication();
+ await begin();
+
+ expect(screen.getByRole('textbox', { name: /Expected arrival time/ })).toBeVisible();
+ expect(screen.getByRole('textbox', { name: /Tell us about your stay/ })).toBeVisible();
+ expect(screen.getByRole('combobox', { name: /Purpose of stay/ })).toBeVisible();
+ expect(screen.getByRole('checkbox', { name: 'Spa' })).toBeVisible();
+ expect(screen.getByRole('checkbox', { name: 'Dining' })).toBeVisible();
+ expect(screen.getByRole('radio', { name: 'Yes' })).toBeVisible();
+ expect(screen.getByRole('radio', { name: 'No' })).toBeVisible();
+ expect(screen.getByLabelText(/Celebration date/)).toHaveAttribute('type', 'date');
+ });
+
+ it('shows human-readable stay dates without shifting calendar days', async () => {
+ renderRequestApplication();
+ await begin();
+
+ const formatDate = (value: string) =>
+ new Intl.DateTimeFormat(undefined, {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ timeZone: 'UTC',
+ }).format(new Date(`${value}T00:00:00.000Z`));
+ const docket = screen.getByLabelText('Your request');
+ expect(docket).toHaveTextContent(formatDate('2026-09-10'));
+ expect(docket).toHaveTextContent(formatDate('2026-09-12'));
+ expect(docket).not.toHaveTextContent('2026-09-10');
+ expect(docket).not.toHaveTextContent('2026-09-12');
+ });
+
+ it('reports configured required questions before advancing', async () => {
+ renderRequestApplication();
+ await begin();
+ await fillCoreGuest();
+
+ fireEvent.submit(screen.getByRole('form', { name: 'Booking request application' }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Expected arrival time is required');
+ expect(screen.getByRole('heading', { name: 'Tell us about your stay' })).toBeVisible();
+ });
+
+ it('marks core fields required, links their errors, and focuses the first invalid field', async () => {
+ renderRequestApplication(requestConfig('required', []));
+ await begin();
+
+ const firstName = screen.getByRole('textbox', { name: 'First name required' });
+ const lastName = screen.getByRole('textbox', { name: 'Last name required' });
+ const email = screen.getByRole('textbox', { name: 'Email required' });
+ expect(firstName).toBeRequired();
+ expect(lastName).toBeRequired();
+ expect(email).toBeRequired();
+ expect(firstName).toHaveAttribute('aria-required', 'true');
+
+ fireEvent.submit(screen.getByRole('form', { name: 'Booking request application' }));
+
+ const alert = await screen.findByRole('alert');
+ expect(alert).toHaveAttribute('id', 'request-application-error');
+ expect(firstName).toHaveFocus();
+ for (const field of [firstName, lastName, email]) {
+ expect(field).toHaveAttribute('aria-invalid', 'true');
+ expect(field).toHaveAttribute('aria-describedby', 'request-application-error');
+ }
+ });
+
+ it('exposes required question semantics and focuses text, select, radio, then multi groups', async () => {
+ const requiredQuestions = [
+ questions[0]!,
+ questions[2]!,
+ questions[4]!,
+ { ...questions[3]!, isRequired: true },
+ ];
+ renderRequestApplication(requestConfig('required', requiredQuestions));
+ await begin();
+ await fillCoreGuest();
+
+ const text = screen.getByRole('textbox', {
+ name: 'Expected arrival time required',
+ });
+ const select = screen.getByRole('combobox', {
+ name: 'Purpose of stay required',
+ });
+ const radioGroup = screen.getByRole('group', {
+ name: 'Travelling with a pet required',
+ });
+ const yes = screen.getByRole('radio', { name: 'Yes' });
+ const multiGroup = screen.getByRole('group', {
+ name: 'Interested experiences required',
+ });
+ const spa = screen.getByRole('checkbox', { name: 'Spa' });
+ expect(text).toBeRequired();
+ expect(select).toBeRequired();
+ expect(yes).toBeRequired();
+ expect(radioGroup).toHaveAttribute('aria-required', 'true');
+ expect(multiGroup).toHaveAttribute('aria-required', 'true');
+
+ const form = screen.getByRole('form', { name: 'Booking request application' });
+ fireEvent.submit(form);
+ expect(text).toHaveFocus();
+ expect(text).toHaveAttribute('aria-invalid', 'true');
+ expect(text).toHaveAttribute('aria-describedby', 'request-application-error');
+
+ await userEvent.type(text, '18:00');
+ fireEvent.submit(form);
+ expect(select).toHaveFocus();
+ expect(select).toHaveAttribute('aria-invalid', 'true');
+
+ await userEvent.selectOptions(select, 'Leisure');
+ fireEvent.submit(form);
+ expect(yes).toHaveFocus();
+ expect(radioGroup).toHaveAttribute('aria-invalid', 'true');
+ expect(radioGroup).toHaveAttribute('aria-describedby', 'request-application-error');
+
+ await userEvent.click(yes);
+ fireEvent.submit(form);
+ expect(spa).toHaveFocus();
+ expect(multiGroup).toHaveAttribute('aria-invalid', 'true');
+ expect(multiGroup).toHaveAttribute('aria-describedby', 'request-application-error');
+ });
+
+ it('does not commit local edits when the guest navigates back', async () => {
+ renderRequestApplication(requestConfig(), { withStoredApplication: true });
+ await begin();
+
+ await userEvent.clear(screen.getByLabelText(/^First name/));
+ await userEvent.type(screen.getByLabelText(/^First name/), 'Changed');
+ await userEvent.clear(screen.getByLabelText(/Expected arrival time/));
+ await userEvent.type(screen.getByLabelText(/Expected arrival time/), 'Changed answer');
+ await userEvent.click(screen.getByRole('button', { name: /Back to extras/ }));
+
+ const state = screen.getByRole('status');
+ expect(state).toHaveTextContent('Stored');
+ expect(state).toHaveTextContent('Stored answer');
+ expect(state).not.toHaveTextContent('Changed');
+ });
+
+ it('submits directly without Stripe when card collection is disabled', async () => {
+ const onlyQuestion = [{ ...questions[0]!, isRequired: true }];
+ renderRequestApplication(requestConfig('disabled', onlyQuestion), {
+ receiptInLayout: true,
+ });
+ await begin();
+ await fillCoreGuest();
+ await userEvent.type(screen.getByLabelText(/Expected arrival time/), '18:00');
+ await userEvent.click(screen.getByRole('button', { name: 'Submit booking request' }));
+
+ await waitFor(() => expect(api.submitRequest).toHaveBeenCalledOnce());
+ const payload = api.submitRequest.mock.calls[0]![0] as Record;
+ expect(payload).toMatchObject({
+ roomTypeId: ROOM_TYPE_ID,
+ ratePlanId: RATE_PLAN_ID,
+ guestFirstName: 'Ada',
+ guestEmail: 'ada@example.com',
+ applicationAnswers: { [questions[0]!.id]: '18:00' },
+ });
+ expect(payload).not.toHaveProperty('setupIntentId');
+ expect(payload).not.toHaveProperty('paymentMethodId');
+ expect(payload).not.toHaveProperty('cardLastFour');
+ expect(api.createRequestPaymentMethodSetup).not.toHaveBeenCalled();
+
+ expect(await screen.findByText('Request received · Pending review')).toBeVisible();
+ expect(screen.getByText(/email/i)).toBeVisible();
+ expect(screen.queryByText(/booking confirmed/i)).not.toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: /manage/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: /cancel/i })).not.toBeInTheDocument();
+ });
+
+ it('lets the receipt header return home without redirecting back to the receipt', async () => {
+ const onlyQuestion = [{ ...questions[0]!, isRequired: true }];
+ renderRequestApplication(requestConfig('disabled', onlyQuestion), {
+ receiptInLayout: true,
+ });
+ await submitDisabledRequest();
+
+ await userEvent.click(screen.getByRole('link', { name: 'Hotel Mirador' }));
+
+ expect(await screen.findByRole('button', { name: 'Begin test flow' })).toBeVisible();
+ expect(screen.queryByText('Request received · Pending review')).not.toBeInTheDocument();
+ });
+
+ it('resets a completed request from the receipt header before a second request', async () => {
+ const onlyQuestion = [{ ...questions[0]!, isRequired: true }];
+ renderRequestApplication(requestConfig('disabled', onlyQuestion), {
+ receiptInLayout: true,
+ flowStateProbe: true,
+ withStoredPayment: true,
+ });
+ await submitDisabledRequest();
+ const firstKey = api.submitRequest.mock.calls[0]![0].idempotencyKey;
+
+ await userEvent.click(screen.getByRole('link', { name: 'Hotel Mirador' }));
+
+ expect(await screen.findByRole('button', { name: 'Begin test flow' })).toBeVisible();
+ expect(screen.queryByText('Request received · Pending review')).not.toBeInTheDocument();
+ const resetState = screen.getByRole('status', { name: 'Booking flow state' });
+ expect(resetState).toHaveTextContent('"requestAcknowledgement":null');
+ expect(resetState).toHaveTextContent('"requestSubmissionStatus":"idle"');
+ expect(resetState).toHaveTextContent('"guest":null');
+ expect(resetState).toHaveTextContent('"applicationAnswers":{}');
+ expect(resetState).toHaveTextContent('"setupIntentId":null');
+ expect(resetState).toHaveTextContent('"setupIntentConsentText":null');
+ expect(resetState).toHaveTextContent('"requestIdempotencyKey":null');
+
+ await begin();
+ expect(screen.getByLabelText(/^First name/)).toHaveValue('');
+ expect(screen.getByLabelText(/^Last name/)).toHaveValue('');
+ expect(screen.getByLabelText(/^Email/)).toHaveValue('');
+ expect(screen.getByLabelText(/Expected arrival time/)).toHaveValue('');
+ await fillCoreGuest();
+ await userEvent.type(screen.getByLabelText(/Expected arrival time/), '20:00');
+ await userEvent.click(screen.getByRole('button', { name: 'Submit booking request' }));
+
+ await waitFor(() => expect(api.submitRequest).toHaveBeenCalledTimes(2));
+ expect(api.submitRequest.mock.calls[1]![0].idempotencyKey).not.toBe(firstKey);
+ expect(await screen.findByText('Request received · Pending review')).toBeVisible();
+ });
+
+ it('still lets the receipt action reset the flow and start a new search', async () => {
+ const onlyQuestion = [{ ...questions[0]!, isRequired: true }];
+ renderRequestApplication(requestConfig('disabled', onlyQuestion));
+ await submitDisabledRequest();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Start a new search' }));
+
+ expect(await screen.findByRole('button', { name: 'Begin test flow' })).toBeVisible();
+ expect(screen.queryByText('Request received · Pending review')).not.toBeInTheDocument();
+ });
+
+ it('shows a server failure and allows a safe retry with the same submission key', async () => {
+ api.submitRequest
+ .mockRejectedValueOnce(new Error('The selected stay is no longer available.'))
+ .mockResolvedValueOnce({
+ requestId: 'request-123',
+ status: 'pending',
+ message: 'Pending review.',
+ });
+ const onlyQuestion = [{ ...questions[0]!, isRequired: true }];
+ renderRequestApplication(requestConfig('disabled', onlyQuestion));
+ await begin();
+ await fillCoreGuest();
+ await userEvent.type(screen.getByLabelText(/Expected arrival time/), '18:00');
+
+ await userEvent.click(screen.getByRole('button', { name: 'Submit booking request' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'The selected stay is no longer available.',
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Submit booking request' }));
+
+ await waitFor(() => expect(api.submitRequest).toHaveBeenCalledTimes(2));
+ expect(api.submitRequest.mock.calls[0]![0].idempotencyKey).toBe(
+ api.submitRequest.mock.calls[1]![0].idempotencyKey,
+ );
+ });
+
+ it('keeps an in-flight request alive, blocks leaving, and stores the receipt once', async () => {
+ let resolveRequest!: (value: {
+ requestId: string;
+ status: 'pending';
+ message: string;
+ }) => void;
+ api.submitRequest.mockReturnValue(
+ new Promise((resolve) => {
+ resolveRequest = resolve;
+ }),
+ );
+ const onlyQuestion = [{ ...questions[0]!, isRequired: true }];
+ renderGuardedRequestApplication(requestConfig('disabled', onlyQuestion));
+ await begin();
+ await fillCoreGuest();
+ await userEvent.type(screen.getByLabelText(/Expected arrival time/), '18:00');
+
+ const submit = screen.getByRole('button', { name: 'Submit booking request' });
+ await userEvent.dblClick(submit);
+ await waitFor(() => expect(api.submitRequest).toHaveBeenCalledOnce());
+ expect(submit).toBeDisabled();
+ expect(screen.getByRole('button', { name: /Back to extras/ })).toBeDisabled();
+
+ const unload = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(unload);
+ expect(unload.defaultPrevented).toBe(true);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Attempt to leave' }));
+ expect(screen.getByRole('heading', { name: 'Tell us about your stay' })).toBeVisible();
+ expect(screen.queryByText('Navigation escaped the request')).not.toBeInTheDocument();
+
+ resolveRequest({
+ requestId: 'request-replayed',
+ status: 'pending',
+ message: 'The idempotent request is pending review.',
+ });
+
+ expect(await screen.findByText('Request received · Pending review')).toBeVisible();
+ expect(screen.getByText(/idempotent request is pending review/i)).toBeVisible();
+ expect(api.submitRequest).toHaveBeenCalledOnce();
+ });
+
+ it('re-enables navigation after an in-flight request fails', async () => {
+ api.submitRequest.mockRejectedValueOnce(new Error('Request submission failed.'));
+ const onlyQuestion = [{ ...questions[0]!, isRequired: true }];
+ renderGuardedRequestApplication(requestConfig('disabled', onlyQuestion));
+ await begin();
+ await fillCoreGuest();
+ await userEvent.type(screen.getByLabelText(/Expected arrival time/), '18:00');
+
+ await userEvent.click(screen.getByRole('button', { name: 'Submit booking request' }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Request submission failed.');
+ expect(screen.getByRole('button', { name: /Back to extras/ })).toBeEnabled();
+ const unload = new Event('beforeunload', { cancelable: true });
+ window.dispatchEvent(unload);
+ expect(unload.defaultPrevented).toBe(false);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Attempt to leave' }));
+ expect(await screen.findByText('Navigation escaped the request')).toBeVisible();
+ });
+});
+
+describe('instant booking regression', () => {
+ it('keeps the existing guest, payment, booking client, and confirmation path', async () => {
+ vi.clearAllMocks();
+ api.config.mockResolvedValue({
+ ...requestConfig('disabled', []),
+ bookingMode: 'instant',
+ });
+ api.book.mockResolvedValue({
+ success: true,
+ confirmationNumber: 'HAIP-12345678',
+ reservationId: 'reservation-123',
+ status: 'confirmed',
+ currencyCode: 'EUR',
+ grandTotal: '640.00',
+ deposit: null,
+ lineItems: quote.lineItems,
+ cancellationPolicy: 'Flexible',
+ });
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+
+ render(
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+ ,
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Begin test flow' }));
+ await fillCoreGuest();
+ await userEvent.click(screen.getByRole('button', { name: 'Continue to payment' }));
+ await userEvent.click(await screen.findByRole('button', { name: 'Confirm booking' }));
+
+ await waitFor(() => expect(api.book).toHaveBeenCalledOnce());
+ expect(await screen.findByText('Booking confirmed')).toBeVisible();
+ expect(screen.getByText('HAIP-12345678')).toBeVisible();
+ expect(api.submitRequest).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/booking/src/pages/RequestApplication.tsx b/apps/booking/src/pages/RequestApplication.tsx
new file mode 100644
index 00000000..c9a2393d
--- /dev/null
+++ b/apps/booking/src/pages/RequestApplication.tsx
@@ -0,0 +1,319 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import type {
+ BookingApplicationAnswer,
+ BookingApplicationAnswers,
+ BookingFormQuestion,
+} from '../api/types';
+import { errorMessage } from '../api/client';
+import { Button } from '../components/Button';
+import { ConfiguredQuestion } from '../components/ConfiguredQuestion';
+import { Field, inputClass } from '../components/Field';
+import { RequestFlowFrame } from '../components/RequestStayDocket';
+import { useBookingFlow } from '../context/BookingFlowContext';
+import { useConfig } from '../context/ConfigContext';
+import { requestPayload } from '../lib/requestPayload';
+
+function missingRequiredAnswer(
+ questions: BookingFormQuestion[],
+ answers: BookingApplicationAnswers,
+): { message: string; invalidIds: string[]; focusId: string } | undefined {
+ const missing = questions.filter((question) => {
+ if (!question.isRequired) return false;
+ const answer = answers[question.id];
+ return (
+ answer === undefined ||
+ (typeof answer === 'string' && answer.trim().length === 0) ||
+ (Array.isArray(answer) && answer.length === 0)
+ );
+ });
+ const first = missing[0];
+ if (!first) return undefined;
+ const baseId = `request-question-${first.id}`;
+ return {
+ message: `${first.label} is required.`,
+ invalidIds: missing.map((question) => question.id),
+ focusId:
+ first.type === 'yes_no' || first.type === 'multi_select'
+ ? `${baseId}-option-0`
+ : baseId,
+ };
+}
+
+interface ValidationIssue {
+ message: string;
+ invalidIds: string[];
+ focusId: string;
+}
+
+const APPLICATION_ERROR_ID = 'request-application-error';
+
+export function RequestApplication() {
+ const navigate = useNavigate();
+ const { config, isLoading } = useConfig();
+ const flow = useBookingFlow();
+ const [firstName, setFirstName] = useState(flow.guest?.firstName ?? '');
+ const [lastName, setLastName] = useState(flow.guest?.lastName ?? '');
+ const [email, setEmail] = useState(flow.guest?.email ?? '');
+ const [phone, setPhone] = useState(flow.guest?.phone ?? '');
+ const [specialRequests, setSpecialRequests] = useState(
+ flow.guest?.specialRequests ?? '',
+ );
+ const [answers, setAnswers] = useState(() => ({
+ ...flow.applicationAnswers,
+ }));
+ const [validationError, setValidationError] = useState();
+
+ useEffect(() => {
+ if (
+ !flow.criteria ||
+ !flow.roomType ||
+ !flow.rate ||
+ !flow.quote
+ ) {
+ navigate('/', { replace: true });
+ }
+ }, [flow.criteria, flow.roomType, flow.rate, flow.quote, navigate]);
+
+ useEffect(() => {
+ if (!isLoading && config?.bookingMode !== 'request') {
+ navigate('/guest', { replace: true });
+ }
+ }, [config?.bookingMode, isLoading, navigate]);
+
+ if (
+ isLoading ||
+ config?.bookingMode !== 'request' ||
+ !flow.criteria ||
+ !flow.roomType ||
+ !flow.rate ||
+ !flow.quote
+ ) {
+ return null;
+ }
+
+ const setAnswer = (id: string, value?: BookingApplicationAnswer) => {
+ setAnswers((current) => {
+ if (value === undefined) {
+ const next = { ...current };
+ delete next[id];
+ return next;
+ }
+ return { ...current, [id]: value };
+ });
+ };
+
+ const submit = (event: React.FormEvent) => {
+ event.preventDefault();
+ const cleanGuest = {
+ firstName: firstName.trim(),
+ lastName: lastName.trim(),
+ email: email.trim(),
+ phone: phone.trim() || undefined,
+ specialRequests: specialRequests.trim() || undefined,
+ };
+
+ const missingCoreFields = [
+ !cleanGuest.firstName ? 'request-first-name' : undefined,
+ !cleanGuest.lastName ? 'request-last-name' : undefined,
+ !cleanGuest.email ? 'request-email' : undefined,
+ ].filter((id): id is string => Boolean(id));
+ if (missingCoreFields.length > 0) {
+ const issue = {
+ message: 'First name, last name and email are required.',
+ invalidIds: missingCoreFields,
+ focusId: missingCoreFields[0]!,
+ };
+ setValidationError(issue);
+ document.getElementById(issue.focusId)?.focus();
+ return;
+ }
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanGuest.email)) {
+ const issue = {
+ message: 'Please enter a valid email address.',
+ invalidIds: ['request-email'],
+ focusId: 'request-email',
+ };
+ setValidationError(issue);
+ document.getElementById(issue.focusId)?.focus();
+ return;
+ }
+ const questionError = missingRequiredAnswer(config.formQuestions, answers);
+ if (questionError) {
+ setValidationError(questionError);
+ document.getElementById(questionError.focusId)?.focus();
+ return;
+ }
+
+ setValidationError(undefined);
+ flow.setGuest(cleanGuest);
+ flow.setApplicationAnswers({ ...answers });
+ flow.setSetupIntentId(undefined);
+ flow.setSetupIntentConsentText(undefined);
+ flow.setRequestAcknowledgement(undefined);
+ const idempotencyKey = flow.ensureRequestIdempotencyKey();
+
+ if (config.paymentMethodCollection !== 'disabled') {
+ flow.rotateRequestPaymentSetupKey();
+ navigate('/request/payment');
+ return;
+ }
+
+ void flow
+ .submitRequest(
+ requestPayload({
+ idempotencyKey,
+ criteria: flow.criteria!,
+ roomType: flow.roomType!,
+ rate: flow.rate!,
+ guest: cleanGuest,
+ serviceIds: flow.serviceIds,
+ applicationAnswers: answers,
+ }),
+ )
+ .catch(() => undefined);
+ };
+
+ const isSubmitting = flow.requestSubmissionStatus === 'pending';
+ const invalidIds = new Set(validationError?.invalidIds);
+ const fieldErrorProps = (id: string) => ({
+ 'aria-invalid': invalidIds.has(id) || undefined,
+ 'aria-describedby': invalidIds.has(id)
+ ? APPLICATION_ERROR_ID
+ : undefined,
+ });
+
+ return (
+
+ navigate('/extras')}
+ disabled={isSubmitting}
+ >
+ ← Back to extras
+
+
+
+ Step 2 of 3 · Your details
+
+
+ Tell us about your stay
+
+
+ Share your contact details and anything the hotel should review with your
+ request.
+
+
+
+
+
+ );
+}
diff --git a/apps/booking/src/pages/RequestFlow.e2e.test.tsx b/apps/booking/src/pages/RequestFlow.e2e.test.tsx
new file mode 100644
index 00000000..72d3e1a9
--- /dev/null
+++ b/apps/booking/src/pages/RequestFlow.e2e.test.tsx
@@ -0,0 +1,272 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter } from 'react-router-dom';
+import type {
+ BookResponse,
+ BookingConfig,
+ QuoteResponse,
+ SearchResponse,
+} from '../api/types';
+import App from '../App';
+import { BookingFlowProvider } from '../context/BookingFlowContext';
+import { ConfigProvider } from '../context/ConfigContext';
+
+const api = vi.hoisted(() => ({
+ config: vi.fn(),
+ search: vi.fn(),
+ quote: vi.fn(),
+ listServices: vi.fn(),
+ book: vi.fn(),
+ createRequestPaymentMethodSetup: vi.fn(),
+ submitRequest: vi.fn(),
+ getBooking: vi.fn(),
+ cancelBooking: vi.fn(),
+}));
+
+const stripe = vi.hoisted(() => ({
+ loadStripe: vi.fn(() => Promise.resolve({})),
+ createPaymentMethod: vi.fn(async () => ({
+ paymentMethod: {
+ id: 'pm_instant_paid',
+ card: { last4: '6789', brand: 'visa' },
+ },
+ })),
+}));
+
+vi.mock('../api/client', () => ({
+ bookingApi: api,
+ errorMessage: (error: unknown) =>
+ error instanceof Error ? error.message : 'Something went wrong',
+}));
+
+vi.mock('@stripe/stripe-js', () => ({
+ loadStripe: stripe.loadStripe,
+}));
+
+vi.mock('@stripe/react-stripe-js', () => ({
+ Elements: ({ children }: { children: React.ReactNode }) => children,
+ CardElement: () =>
,
+ useStripe: () => ({ createPaymentMethod: stripe.createPaymentMethod }),
+ useElements: () => ({ getElement: () => ({}) }),
+}));
+
+const ROOM_TYPE_ID = '11111111-1111-4111-8111-111111111111';
+const RATE_PLAN_ID = '22222222-2222-4222-8222-222222222222';
+const QUESTION_ID = '33333333-3333-4333-8333-333333333333';
+
+const searchResponse: SearchResponse = {
+ propertyId: '44444444-4444-4444-8444-444444444444',
+ checkIn: '2026-10-10',
+ checkOut: '2026-10-12',
+ branding: { displayName: 'Hotel Vertical' },
+ results: [{
+ propertyName: 'Hotel Vertical',
+ roomTypes: [{
+ roomTypeId: ROOM_TYPE_ID,
+ roomTypeName: 'Garden suite',
+ rates: [{
+ ratePlanId: RATE_PLAN_ID,
+ ratePlanName: 'Flexible',
+ totalAmount: 200,
+ currencyCode: 'EUR',
+ }],
+ }],
+ }],
+};
+
+const quote: QuoteResponse = {
+ nights: 2,
+ currencyCode: 'EUR',
+ lineItems: [
+ { date: '2026-10-10', rate: '100.00', tax: '0.00' },
+ { date: '2026-10-11', rate: '100.00', tax: '0.00' },
+ ],
+ roomTotal: '200.00',
+ taxTotal: '0.00',
+ services: [],
+ servicesTotal: '0.00',
+ servicesTaxTotal: '0.00',
+ grandTotal: '200.00',
+ depositPolicy: { type: 'percentage', percentage: 30, refundable: true },
+ depositDue: '60.00',
+ cancellationPolicy: {
+ type: 'tiered',
+ description: 'Free cancellation before arrival.',
+ freeCancelHoursBeforeArrival: 24,
+ },
+};
+
+function config(bookingMode: BookingConfig['bookingMode']): BookingConfig {
+ return {
+ isEnabled: true,
+ displayName: 'Hotel Vertical',
+ depositPolicy: { type: 'percentage', percentage: 30, refundable: true },
+ stripePublishableKey: 'pk_test_present_but_collection_disabled',
+ sellableRoomTypeIds: [ROOM_TYPE_ID],
+ sellableRatePlanIds: [RATE_PLAN_ID],
+ bookingMode,
+ paymentMethodCollection: 'disabled',
+ formQuestions: bookingMode === 'request'
+ ? [{
+ id: QUESTION_ID,
+ label: 'Purpose of stay',
+ type: 'single_select',
+ options: ['Leisure', 'Business'],
+ order: 0,
+ isActive: true,
+ isRequired: true,
+ }]
+ : [],
+ };
+}
+
+function renderWidget(bookingMode: BookingConfig['bookingMode']) {
+ api.config.mockResolvedValue(config(bookingMode));
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+ return render(
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+async function selectQuotedStay() {
+ await screen.findByRole('heading', { name: 'Find a room' });
+ await userEvent.click(screen.getByRole('button', { name: 'Search availability' }));
+ await screen.findByRole('heading', { name: 'Available rooms' });
+ await userEvent.click(await screen.findByRole('button', { name: 'Select' }));
+ await screen.findByRole('heading', { name: 'Garden suite' });
+ await userEvent.click(await screen.findByRole('button', { name: 'Continue' }));
+ await screen.findByRole('heading', { name: 'Enhance your stay' });
+}
+
+describe('Booking widget request/instant rollout', () => {
+ let consoleError: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ api.search.mockResolvedValue(searchResponse);
+ api.quote.mockResolvedValue(quote);
+ api.listServices.mockResolvedValue({ propertyId: searchResponse.propertyId, data: [] });
+ api.submitRequest.mockResolvedValue({
+ requestId: '55555555-5555-4555-8555-555555555555',
+ status: 'pending',
+ message: 'Your booking request has been received and is pending review.',
+ });
+ api.book.mockResolvedValue({
+ success: true,
+ confirmationNumber: 'HAIP-INSTANT-1',
+ reservationId: '66666666-6666-4666-8666-666666666666',
+ status: 'confirmed',
+ currencyCode: 'EUR',
+ grandTotal: '200.00',
+ deposit: null,
+ lineItems: quote.lineItems,
+ cancellationPolicy: 'Free cancellation before arrival.',
+ } satisfies BookResponse);
+ });
+
+ afterEach(() => {
+ consoleError.mockRestore();
+ cleanup();
+ });
+
+ it('submits the configured request flow without loading Stripe or exposing guest management', async () => {
+ renderWidget('request');
+ await selectQuotedStay();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Continue to your details' }));
+ await screen.findByRole('heading', { name: 'Tell us about your stay' });
+ await userEvent.type(screen.getByLabelText(/^First name/), 'Ada');
+ await userEvent.type(screen.getByLabelText(/^Last name/), 'Lovelace');
+ await userEvent.type(screen.getByLabelText(/^Email/), 'ada@example.com');
+ await userEvent.selectOptions(screen.getByLabelText(/^Purpose of stay/), 'Leisure');
+ await userEvent.click(screen.getByRole('button', { name: 'Submit booking request' }));
+
+ expect(await screen.findByText('Request received · Pending review')).toBeVisible();
+ expect(screen.getByText(/This is not a confirmed reservation/i)).toBeVisible();
+ expect(screen.getByText(/You have not been charged/i)).toBeVisible();
+ expect(screen.queryAllByRole('link', {
+ name: /manage|cancel/i,
+ hidden: true,
+ })).toHaveLength(0);
+ expect(screen.queryAllByRole('button', {
+ name: /manage|cancel/i,
+ hidden: true,
+ })).toHaveLength(0);
+ expect(screen.queryByText(/manage (this|your) booking/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/cancel (this|your) booking/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/confirmation number/i)).not.toBeInTheDocument();
+
+ expect(api.submitRequest).toHaveBeenCalledOnce();
+ expect(api.submitRequest.mock.calls[0]![0]).toMatchObject({
+ roomTypeId: ROOM_TYPE_ID,
+ ratePlanId: RATE_PLAN_ID,
+ guestFirstName: 'Ada',
+ guestLastName: 'Lovelace',
+ guestEmail: 'ada@example.com',
+ applicationAnswers: { [QUESTION_ID]: 'Leisure' },
+ });
+ expect(api.submitRequest.mock.calls[0]![0]).not.toHaveProperty('setupIntentId');
+ expect(api.createRequestPaymentMethodSetup).not.toHaveBeenCalled();
+ expect(stripe.loadStripe).not.toHaveBeenCalled();
+ expect(api.book).not.toHaveBeenCalled();
+ expect(consoleError.mock.calls.flat().join(' ')).not.toContain('Maximum update depth');
+ });
+
+ it('keeps an instant deposit payable when request-card collection is disabled by backfill', async () => {
+ renderWidget('instant');
+ await selectQuotedStay();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Continue to guest details' }));
+ await screen.findByRole('heading', { name: 'Guest details' });
+ await userEvent.type(screen.getByLabelText(/^First name/), 'Grace');
+ await userEvent.type(screen.getByLabelText(/^Last name/), 'Hopper');
+ await userEvent.type(screen.getByLabelText(/^Email/), 'grace@example.com');
+ await userEvent.click(screen.getByRole('button', { name: 'Continue to payment' }));
+ await screen.findByRole('heading', { name: 'Payment' });
+ expect(screen.getByText(/Deposit due now:/i)).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Confirm booking' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Pay & confirm booking' })).toBeVisible();
+ expect(stripe.loadStripe).toHaveBeenCalledWith(
+ 'pk_test_present_but_collection_disabled',
+ );
+ expect(api.book).not.toHaveBeenCalled();
+ expect(screen.queryByText('Booking confirmed')).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Pay & confirm booking' }));
+
+ expect(await screen.findByText('Booking confirmed')).toBeVisible();
+ expect(screen.getByText('HAIP-INSTANT-1')).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Manage this booking' })).toBeVisible();
+ await waitFor(() => expect(api.book).toHaveBeenCalledOnce());
+ expect(api.book.mock.calls[0]![0]).toMatchObject({
+ roomTypeId: ROOM_TYPE_ID,
+ ratePlanId: RATE_PLAN_ID,
+ guestFirstName: 'Grace',
+ guestLastName: 'Hopper',
+ guestEmail: 'grace@example.com',
+ paymentToken: 'pm_instant_paid',
+ cardLastFour: '6789',
+ cardBrand: 'visa',
+ });
+ expect(api.submitRequest).not.toHaveBeenCalled();
+ expect(api.createRequestPaymentMethodSetup).not.toHaveBeenCalled();
+ expect(stripe.createPaymentMethod).toHaveBeenCalledOnce();
+ expect(consoleError.mock.calls.flat().join(' ')).not.toContain('Maximum update depth');
+ });
+});
diff --git a/apps/booking/src/pages/RequestPayment.test.tsx b/apps/booking/src/pages/RequestPayment.test.tsx
new file mode 100644
index 00000000..5afa9948
--- /dev/null
+++ b/apps/booking/src/pages/RequestPayment.test.tsx
@@ -0,0 +1,607 @@
+import { StrictMode, type CSSProperties } from 'react';
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
+import type { BookingConfig, QuoteResponse, SearchRate, SearchRoomType } from '../api/types';
+import { BookingFlowProvider, useBookingFlow } from '../context/BookingFlowContext';
+import { ConfigProvider } from '../context/ConfigContext';
+import { RequestPayment } from './RequestPayment';
+import { RequestReceived } from './RequestReceived';
+import { StripeSetupForm } from '../components/StripeSetupForm';
+
+const mocks = vi.hoisted(() => ({
+ config: vi.fn(),
+ createSetup: vi.fn(),
+ submitRequest: vi.fn(),
+ loadStripe: vi.fn(),
+ confirmSetup: vi.fn(),
+ elementsOptions: vi.fn(),
+}));
+
+vi.mock('../api/client', () => ({
+ bookingApi: {
+ config: mocks.config,
+ createRequestPaymentMethodSetup: mocks.createSetup,
+ submitRequest: mocks.submitRequest,
+ },
+ errorMessage: (error: unknown) =>
+ error instanceof Error ? error.message : 'Something went wrong',
+}));
+
+vi.mock('@stripe/stripe-js', () => ({
+ loadStripe: mocks.loadStripe,
+}));
+
+vi.mock('@stripe/react-stripe-js', () => ({
+ Elements: ({
+ children,
+ options,
+ }: {
+ children: React.ReactNode;
+ options: unknown;
+ }) => {
+ mocks.elementsOptions(options);
+ return {children}
;
+ },
+ PaymentElement: () =>
,
+ useElements: () => ({ id: 'elements' }),
+ useStripe: () => ({ confirmSetup: mocks.confirmSetup }),
+}));
+
+const ROOM_TYPE_ID = '11111111-1111-4111-8111-111111111111';
+const RATE_PLAN_ID = '22222222-2222-4222-8222-222222222222';
+const QUESTION_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
+
+const quote: QuoteResponse = {
+ nights: 2,
+ currencyCode: 'EUR',
+ lineItems: [{ date: '2026-09-10', rate: '290.00', tax: '30.00' }],
+ roomTotal: '580.00',
+ taxTotal: '60.00',
+ grandTotal: '640.00',
+ depositPolicy: { type: 'none', refundable: true },
+ depositDue: '0.00',
+};
+
+const roomType: SearchRoomType = {
+ roomTypeId: ROOM_TYPE_ID,
+ roomTypeName: 'Deluxe room',
+};
+
+const rate: SearchRate = {
+ ratePlanId: RATE_PLAN_ID,
+ totalAmount: 640,
+ currencyCode: 'EUR',
+};
+
+function config(
+ policy: BookingConfig['paymentMethodCollection'],
+ options: {
+ stripePublishableKey?: string | null;
+ paymentMethodClientMode?: BookingConfig['paymentMethodClientMode'];
+ } = {},
+): BookingConfig {
+ return {
+ isEnabled: true,
+ displayName: 'Hotel Mirador',
+ depositPolicy: { type: 'none', refundable: true },
+ stripePublishableKey: options.stripePublishableKey === undefined
+ ? 'pk_test_public'
+ : options.stripePublishableKey,
+ sellableRoomTypeIds: [ROOM_TYPE_ID],
+ sellableRatePlanIds: [RATE_PLAN_ID],
+ bookingMode: 'request',
+ paymentMethodCollection: policy,
+ paymentMethodClientMode: options.paymentMethodClientMode ?? 'stripe',
+ formQuestions: [],
+ };
+}
+
+function SeedPayment({ prepareRequestKey = false }: { prepareRequestKey?: boolean }) {
+ const flow = useBookingFlow();
+ const navigate = useNavigate();
+
+ return (
+ {
+ flow.setCriteria({
+ checkIn: '2026-09-10',
+ checkOut: '2026-09-12',
+ adults: 2,
+ children: 0,
+ });
+ flow.setSelection(roomType, rate);
+ flow.setQuote(quote);
+ flow.setGuest({
+ firstName: 'Ada',
+ lastName: 'Lovelace',
+ email: 'ada@example.com',
+ });
+ flow.setApplicationAnswers({ [QUESTION_ID]: '18:00' });
+ if (prepareRequestKey) flow.ensureRequestIdempotencyKey();
+ navigate('/request/payment');
+ }}
+ >
+ Begin payment
+
+ );
+}
+
+function EditApplication() {
+ const flow = useBookingFlow();
+ const navigate = useNavigate();
+ const continueToPayment = (email: string) => {
+ flow.setGuest({ ...flow.guest!, email });
+ flow.setSetupIntentId(undefined);
+ flow.setSetupIntentConsentText(undefined);
+ flow.rotateRequestPaymentSetupKey();
+ navigate('/request/payment');
+ };
+ return (
+
+
Application page
+
continueToPayment('grace@example.com')}>Continue with edited email
+
continueToPayment(flow.guest!.email)}>Continue unchanged
+
+ );
+}
+
+function renderPayment(
+ policy: BookingConfig['paymentMethodCollection'],
+ widgetStyle?: CSSProperties,
+ options: {
+ strict?: boolean;
+ prepareRequestKey?: boolean;
+ stripePublishableKey?: string | null;
+ paymentMethodClientMode?: BookingConfig['paymentMethodClientMode'];
+ } = {},
+) {
+ mocks.config.mockResolvedValue(config(policy, options));
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ const application = (
+
+
+
+
+
+
+ }
+ />
+ } />
+ } />
+ } />
+
+
+
+
+
+
+ );
+ return render(options.strict ? {application} : application);
+}
+
+async function begin() {
+ await userEvent.click(screen.getByRole('button', { name: 'Begin payment' }));
+ await screen.findByRole('heading', { name: 'Secure your request' });
+}
+
+describe('RequestPayment', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.loadStripe.mockResolvedValue({});
+ mocks.createSetup.mockResolvedValue({
+ setupIntentId: 'seti_server',
+ clientSecret: 'seti_server_secret_value',
+ clientMode: 'stripe',
+ });
+ mocks.confirmSetup.mockResolvedValue({
+ setupIntent: { id: 'seti_succeeded', status: 'succeeded' },
+ });
+ mocks.submitRequest.mockResolvedValue({
+ requestId: 'request-123',
+ status: 'pending',
+ message: 'Your booking request has been received and is pending review.',
+ });
+ });
+
+ it('lets an optional card be explicitly skipped without loading Stripe', async () => {
+ renderPayment('optional');
+ await begin();
+
+ expect(screen.getByRole('button', { name: 'Add a card' })).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Continue without a card' })).toBeVisible();
+ expect(mocks.createSetup).not.toHaveBeenCalled();
+ expect(mocks.loadStripe).not.toHaveBeenCalled();
+ expect(screen.queryByLabelText('Secure card entry')).not.toBeInTheDocument();
+ expect(screen.getByText(/If you add a card, it will be securely saved/i)).toBeVisible();
+ expect(screen.queryByText(/^Your card will be securely saved/i)).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Continue without a card' }));
+
+ await waitFor(() => expect(mocks.submitRequest).toHaveBeenCalledOnce());
+ const payload = mocks.submitRequest.mock.calls[0]![0] as Record;
+ expect(payload).not.toHaveProperty('setupIntentId');
+ expect(payload).not.toHaveProperty('consentAccepted');
+ expect(await screen.findByText('Request received · Pending review')).toBeVisible();
+ });
+
+ it('does not offer an optional Stripe card when the publishable key is unavailable', async () => {
+ renderPayment('optional', undefined, {
+ stripePublishableKey: null,
+ paymentMethodClientMode: 'stripe',
+ });
+ await begin();
+
+ expect(screen.queryByRole('button', { name: 'Add a card' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Continue without a card' })).toBeVisible();
+ expect(screen.queryByText(/securely saved by Stripe/i)).not.toBeInTheDocument();
+ expect(mocks.createSetup).not.toHaveBeenCalled();
+ });
+
+ it('blocks required submission when card collection is unavailable', async () => {
+ renderPayment('required', undefined, {
+ stripePublishableKey: null,
+ paymentMethodClientMode: 'unsupported',
+ });
+ await begin();
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'Secure card collection is unavailable.',
+ );
+ expect(screen.queryByRole('button', { name: /submit booking request/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Continue without a card' })).not.toBeInTheDocument();
+ expect(mocks.createSetup).not.toHaveBeenCalled();
+ expect(mocks.submitRequest).not.toHaveBeenCalled();
+ });
+
+ it('redirects disabled collection without loading Stripe or requesting a setup', async () => {
+ renderPayment('disabled');
+ await userEvent.click(screen.getByRole('button', { name: 'Begin payment' }));
+
+ expect(await screen.findByText('Application page')).toBeVisible();
+ expect(mocks.createSetup).not.toHaveBeenCalled();
+ expect(mocks.loadStripe).not.toHaveBeenCalled();
+ expect(mocks.submitRequest).not.toHaveBeenCalled();
+ });
+
+ it('loads the Payment Element only after an optional guest chooses to add a card', async () => {
+ renderPayment('optional');
+ await begin();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Add a card' }));
+
+ await waitFor(() => expect(mocks.createSetup).toHaveBeenCalledOnce());
+ expect(mocks.loadStripe).toHaveBeenCalledWith('pk_test_public');
+ expect(await screen.findByLabelText('Secure card entry')).toBeVisible();
+ });
+
+ it('uses a new card-attempt key but stable application provenance after editing email', async () => {
+ renderPayment('required');
+ await begin();
+ await waitFor(() => expect(mocks.createSetup).toHaveBeenCalledOnce());
+ const first = mocks.createSetup.mock.calls[0]![0];
+
+ await userEvent.click(screen.getByRole('button', { name: /Back to your details/ }));
+ await userEvent.click(await screen.findByRole('button', { name: 'Continue with edited email' }));
+
+ await waitFor(() => expect(mocks.createSetup).toHaveBeenCalledTimes(2));
+ const second = mocks.createSetup.mock.calls[1]![0];
+ expect(second.guestEmail).toBe('grace@example.com');
+ expect(second.applicationId).toBe(first.applicationId);
+ expect(second.idempotencyKey).not.toBe(first.idempotencyKey);
+ });
+
+ it.each([
+ { policy: 'required' as const, chooseCard: false },
+ { policy: 'optional' as const, chooseCard: true },
+ ])(
+ 'finishes a deferred $policy setup when the routed payment tree replays in StrictMode',
+ async ({ policy, chooseCard }) => {
+ let resolveSetup!: (value: {
+ setupIntentId: string;
+ clientSecret: string;
+ clientMode: 'stripe';
+ }) => void;
+ mocks.createSetup.mockReturnValue(
+ new Promise((resolve) => {
+ resolveSetup = resolve;
+ }),
+ );
+ renderPayment(policy, undefined, {
+ strict: true,
+ prepareRequestKey: true,
+ });
+ await begin();
+ if (chooseCard) {
+ await userEvent.click(screen.getByRole('button', { name: 'Add a card' }));
+ }
+
+ await waitFor(() => expect(mocks.createSetup).toHaveBeenCalledOnce());
+ expect(screen.getByText('Preparing secure card entry…')).toBeVisible();
+ const setupKey = mocks.createSetup.mock.calls[0]![0].idempotencyKey;
+
+ resolveSetup({
+ setupIntentId: 'seti_deferred',
+ clientSecret: 'seti_deferred_secret_value',
+ clientMode: 'stripe',
+ });
+
+ expect(await screen.findByLabelText('Secure card entry')).toBeVisible();
+ expect(screen.queryByText('Preparing secure card entry…')).not.toBeInTheDocument();
+ expect(mocks.createSetup).toHaveBeenCalledOnce();
+ expect(mocks.createSetup.mock.calls[0]![0].idempotencyKey).toBe(setupKey);
+ },
+ );
+
+ it('surfaces a deferred setup error after the routed tree replays in StrictMode', async () => {
+ let rejectSetup!: (error: Error) => void;
+ mocks.createSetup.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejectSetup = reject;
+ }),
+ );
+ renderPayment('required', undefined, {
+ strict: true,
+ prepareRequestKey: true,
+ });
+ await begin();
+ await waitFor(() => expect(mocks.createSetup).toHaveBeenCalledOnce());
+
+ rejectSetup(new Error('Secure card entry is unavailable.'));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'Secure card entry is unavailable.',
+ );
+ expect(screen.queryByText('Preparing secure card entry…')).not.toBeInTheDocument();
+ expect(mocks.createSetup).toHaveBeenCalledOnce();
+ });
+
+ it('requires consent and a successful SetupIntent before required submission', async () => {
+ renderPayment('required');
+ await begin();
+
+ expect(await screen.findByLabelText('Secure card entry')).toBeVisible();
+ const submit = screen.getByRole('button', { name: 'Save card and submit booking request' });
+ expect(submit).toBeDisabled();
+ expect(screen.getByText(/You will not be charged now/i)).toBeVisible();
+ expect(screen.getByText(/charge amounts explicitly recorded against this stay/i)).toBeVisible();
+
+ await userEvent.click(screen.getByRole('checkbox', { name: /I authorize Hotel Mirador/i }));
+ await userEvent.click(submit);
+
+ await waitFor(() => expect(mocks.confirmSetup).toHaveBeenCalledOnce());
+ expect(mocks.confirmSetup).toHaveBeenCalledWith({
+ elements: { id: 'elements' },
+ redirect: 'if_required',
+ });
+ await waitFor(() => expect(mocks.submitRequest).toHaveBeenCalledOnce());
+ const payload = mocks.submitRequest.mock.calls[0]![0] as Record;
+ expect(payload).toMatchObject({
+ setupIntentId: 'seti_succeeded',
+ consentAccepted: true,
+ consentVersion: 'request-card-v1',
+ });
+ expect(payload).not.toHaveProperty('paymentMethodId');
+ expect(payload).not.toHaveProperty('cardBrand');
+ expect(payload).not.toHaveProperty('cardLastFour');
+ });
+
+ it('uses the local payment simulation instead of loading Stripe for a mock setup', async () => {
+ mocks.createSetup.mockResolvedValue({
+ setupIntentId: 'seti_mock_local',
+ clientSecret: 'seti_mock_local_secret_mock',
+ clientMode: 'mock',
+ });
+ renderPayment('required', undefined, {
+ stripePublishableKey: null,
+ paymentMethodClientMode: 'mock',
+ });
+ await begin();
+
+ expect(await screen.findByText('Local payment simulation')).toBeVisible();
+ expect(mocks.loadStripe).not.toHaveBeenCalled();
+ expect(screen.queryByLabelText('Secure card entry')).not.toBeInTheDocument();
+ expect(screen.queryByText(/card will be securely saved by Stripe/i)).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('checkbox', { name: /I authorize Hotel Mirador/i }));
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Save test card and submit booking request' }),
+ );
+
+ await waitFor(() => expect(mocks.submitRequest).toHaveBeenCalledOnce());
+ expect(mocks.submitRequest.mock.calls[0]![0]).toMatchObject({
+ setupIntentId: 'seti_mock_local',
+ consentAccepted: true,
+ consentVersion: 'request-card-v1',
+ });
+ });
+
+ it('blocks required submission when Stripe does not complete setup', async () => {
+ mocks.confirmSetup.mockResolvedValue({
+ error: { message: 'Your card could not be saved.' },
+ });
+ renderPayment('required');
+ await begin();
+ await screen.findByLabelText('Secure card entry');
+ await userEvent.click(screen.getByRole('checkbox', { name: /I authorize Hotel Mirador/i }));
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Save card and submit booking request' }),
+ );
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Your card could not be saved.');
+ expect(mocks.submitRequest).not.toHaveBeenCalled();
+ });
+
+ it('ignores a late Stripe result after the guest navigates back', async () => {
+ let resolveConfirmation!: (value: {
+ setupIntent: { id: string; status: string };
+ }) => void;
+ mocks.confirmSetup.mockReturnValue(
+ new Promise((resolve) => {
+ resolveConfirmation = resolve;
+ }),
+ );
+ renderPayment('required');
+ await begin();
+ await screen.findByLabelText('Secure card entry');
+ await userEvent.click(screen.getByRole('checkbox', { name: /I authorize Hotel Mirador/i }));
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Save card and submit booking request' }),
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: /Back to your details/ }));
+ expect(await screen.findByText('Application page')).toBeVisible();
+ resolveConfirmation({
+ setupIntent: { id: 'seti_late', status: 'succeeded' },
+ });
+
+ await waitFor(() => expect(mocks.confirmSetup).toHaveBeenCalledOnce());
+ expect(mocks.submitRequest).not.toHaveBeenCalled();
+ expect(screen.getByText('Application page')).toBeVisible();
+
+ const firstSetupKey = mocks.createSetup.mock.calls[0]![0].idempotencyKey;
+ await userEvent.click(screen.getByRole('button', { name: 'Continue unchanged' }));
+ await waitFor(() => expect(mocks.createSetup).toHaveBeenCalledTimes(2));
+ expect(mocks.createSetup.mock.calls[1]![0].idempotencyKey).not.toBe(firstSetupKey);
+ expect(await screen.findByLabelText('Secure card entry')).toBeVisible();
+ });
+
+ it('shows setup and submission server errors without navigating or double submitting', async () => {
+ mocks.createSetup.mockRejectedValueOnce(new Error('Card setup is unavailable.'));
+ renderPayment('required');
+ await begin();
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Card setup is unavailable.');
+ expect(mocks.submitRequest).not.toHaveBeenCalled();
+ });
+
+ it('offers setup retry and optional skip after adding a card fails', async () => {
+ mocks.createSetup
+ .mockRejectedValueOnce(new Error('Card setup is unavailable.'))
+ .mockResolvedValueOnce({
+ setupIntentId: 'seti_retry',
+ clientSecret: 'seti_retry_secret_value',
+ clientMode: 'stripe',
+ });
+ renderPayment('optional');
+ await begin();
+ await userEvent.click(screen.getByRole('button', { name: 'Add a card' }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Card setup is unavailable.');
+ expect(screen.getByRole('button', { name: 'Retry secure card entry' })).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Continue without a card' })).toBeVisible();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Retry secure card entry' }));
+
+ await waitFor(() => expect(mocks.createSetup).toHaveBeenCalledTimes(2));
+ expect(await screen.findByLabelText('Secure card entry')).toBeVisible();
+ });
+
+ it('keeps retry and optional skip available after Stripe rejects the card setup', async () => {
+ mocks.confirmSetup.mockResolvedValueOnce({
+ error: { message: 'Stripe could not save this card.' },
+ });
+ renderPayment('optional');
+ await begin();
+ await userEvent.click(screen.getByRole('button', { name: 'Add a card' }));
+ await screen.findByLabelText('Secure card entry');
+ await userEvent.click(screen.getByRole('checkbox', { name: /I authorize Hotel Mirador/i }));
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Save card and submit booking request' }),
+ );
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'Stripe could not save this card.',
+ );
+ expect(
+ screen.getByRole('button', { name: 'Retry saving card and submit request' }),
+ ).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Continue without a card' })).toBeEnabled();
+ });
+
+ it('prevents a double submission and re-enables controls after a real POST failure', async () => {
+ let rejectRequest!: (error: Error) => void;
+ mocks.submitRequest.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejectRequest = reject;
+ }),
+ );
+ renderPayment('optional');
+ await begin();
+
+ const skip = screen.getByRole('button', { name: 'Continue without a card' });
+ await userEvent.dblClick(skip);
+ await waitFor(() => expect(mocks.submitRequest).toHaveBeenCalledOnce());
+ expect(skip).toBeDisabled();
+ expect(screen.getByRole('button', { name: /Back to your details/ })).toBeDisabled();
+
+ rejectRequest(new Error('The request could not be submitted.'));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'The request could not be submitted.',
+ );
+ expect(screen.getByRole('button', { name: /Back to your details/ })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Continue without a card' })).toBeEnabled();
+ });
+
+ it('derives Stripe appearance from effective widget theme variables', async () => {
+ renderPayment('required', {
+ '--haip-primary': '#126E75',
+ '--haip-text': '#102A43',
+ '--haip-surface': '#FAFCFE',
+ '--haip-radius': '14px',
+ } as CSSProperties);
+ await begin();
+ await screen.findByLabelText('Secure card entry');
+
+ expect(mocks.elementsOptions).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ appearance: {
+ variables: expect.objectContaining({
+ colorPrimary: '#126E75',
+ colorText: '#102A43',
+ colorBackground: '#FAFCFE',
+ borderRadius: '14px',
+ }),
+ },
+ }),
+ );
+ });
+
+ it('completes Stripe setup exactly once when effects replay in StrictMode', async () => {
+ const onConfirmed = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ await userEvent.click(
+ screen.getByRole('checkbox', { name: /I authorize Hotel Mirador/i }),
+ );
+ await userEvent.click(
+ screen.getByRole('button', { name: 'Save card and submit booking request' }),
+ );
+
+ await waitFor(() =>
+ expect(onConfirmed).toHaveBeenCalledOnce(),
+ );
+ expect(onConfirmed).toHaveBeenCalledWith(
+ 'seti_succeeded',
+ expect.stringContaining('I authorize Hotel Mirador'),
+ );
+ expect(
+ screen.getByRole('button', { name: 'Save card and submit booking request' }),
+ ).toBeEnabled();
+ });
+});
diff --git a/apps/booking/src/pages/RequestPayment.tsx b/apps/booking/src/pages/RequestPayment.tsx
new file mode 100644
index 00000000..2db506ba
--- /dev/null
+++ b/apps/booking/src/pages/RequestPayment.tsx
@@ -0,0 +1,450 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Elements } from '@stripe/react-stripe-js';
+import { loadStripe } from '@stripe/stripe-js';
+import { useNavigate } from 'react-router-dom';
+import { bookingApi, errorMessage } from '../api/client';
+import type {
+ RequestPaymentMethodSetupRequest,
+ RequestPaymentMethodSetupResponse,
+} from '../api/types';
+import { Button } from '../components/Button';
+import { RequestFlowFrame } from '../components/RequestStayDocket';
+import { StripeSetupForm } from '../components/StripeSetupForm';
+import { MockSetupForm } from '../components/MockSetupForm';
+import { useBookingFlow } from '../context/BookingFlowContext';
+import { useConfig } from '../context/ConfigContext';
+import { REQUEST_CARD_CONSENT_VERSION } from '../lib/requestCardConsent';
+import { requestPayload } from '../lib/requestPayload';
+
+function effectiveStripeAppearance(config: {
+ primaryColor?: string | null;
+}) {
+ const widget = document.querySelector('.haip-booking');
+ const source = widget ?? document.documentElement;
+ const computed = getComputedStyle(source);
+ const token = (name: string, fallback: string) =>
+ source.style.getPropertyValue(name).trim() ||
+ computed.getPropertyValue(name).trim() ||
+ fallback;
+
+ return {
+ variables: {
+ colorPrimary:
+ source.style.getPropertyValue('--haip-primary').trim() ||
+ config.primaryColor?.trim() ||
+ computed.getPropertyValue('--haip-primary').trim() ||
+ '#0D9488',
+ colorText: token('--haip-text', '#183153'),
+ colorBackground: token('--haip-surface', '#FFFFFF'),
+ colorDanger: '#B42318',
+ borderRadius: token('--haip-radius', '0.375rem'),
+ },
+ };
+}
+
+function RequestPaymentHeading({
+ optional,
+ simulated,
+ cardCollectionAvailable,
+}: {
+ optional: boolean;
+ simulated: boolean;
+ cardCollectionAvailable: boolean;
+}) {
+ return (
+
+
+ Step 3 of 3 · Payment details
+
+
+ Secure your request
+
+
+ {simulated
+ ? 'A test payment method will be saved for this local request. '
+ : optional && !cardCollectionAvailable
+ ? 'No payment method is required for this request. '
+ : optional
+ ? 'If you add a card, it will be securely saved by Stripe. '
+ : 'Your card will be securely saved by Stripe. '}
+ You will not be charged now. {' '}
+ The hotel reviews every request before confirming.
+
+
+ );
+}
+
+interface SetupRequestState {
+ status: 'idle' | 'pending' | 'success' | 'error';
+ data?: RequestPaymentMethodSetupResponse;
+ error?: unknown;
+}
+
+function useRequestPaymentSetup() {
+ const [state, setState] = useState({ status: 'idle' });
+ const generation = useRef(0);
+ const inFlight = useRef<{
+ key: string;
+ promise: Promise;
+ }>();
+
+ useEffect(
+ () => () => {
+ generation.current += 1;
+ },
+ [],
+ );
+
+ const mutate = useCallback((request: RequestPaymentMethodSetupRequest) => {
+ const requestGeneration = ++generation.current;
+ setState({ status: 'pending' });
+ const promise =
+ inFlight.current?.key === request.idempotencyKey
+ ? inFlight.current.promise
+ : bookingApi.createRequestPaymentMethodSetup(request);
+ inFlight.current = { key: request.idempotencyKey, promise };
+ const clearSettledRequest = () => {
+ if (inFlight.current?.promise === promise) inFlight.current = undefined;
+ };
+ void promise.then(
+ (data) => {
+ clearSettledRequest();
+ if (generation.current === requestGeneration) {
+ setState({ status: 'success', data });
+ }
+ },
+ (error: unknown) => {
+ clearSettledRequest();
+ if (generation.current === requestGeneration) {
+ setState({ status: 'error', error });
+ }
+ },
+ );
+ }, []);
+
+ const reset = useCallback(() => {
+ generation.current += 1;
+ setState({ status: 'idle' });
+ }, []);
+
+ return {
+ data: state.data,
+ error: state.error,
+ isError: state.status === 'error',
+ isPending: state.status === 'pending',
+ isSuccess: state.status === 'success',
+ mutate,
+ reset,
+ };
+}
+
+export function RequestPayment() {
+ const navigate = useNavigate();
+ const { config, isLoading } = useConfig();
+ const flow = useBookingFlow();
+ const [optionalCardSelected, setOptionalCardSelected] = useState(false);
+ const publishableKey = config?.stripePublishableKey?.trim();
+ const paymentMethodClientMode = config?.paymentMethodClientMode ?? 'stripe';
+ const cardCollectionAvailable = paymentMethodClientMode === 'mock'
+ || (paymentMethodClientMode === 'stripe' && Boolean(publishableKey));
+ const collectCard =
+ config?.paymentMethodCollection === 'required'
+ || (optionalCardSelected && cardCollectionAvailable);
+
+ useEffect(() => {
+ if (
+ !flow.criteria ||
+ !flow.roomType ||
+ !flow.rate ||
+ !flow.quote ||
+ !flow.guest
+ ) {
+ navigate('/', { replace: true });
+ }
+ }, [
+ flow.criteria,
+ flow.roomType,
+ flow.rate,
+ flow.quote,
+ flow.guest,
+ navigate,
+ ]);
+
+ useEffect(() => {
+ if (!isLoading && config?.bookingMode !== 'request') {
+ navigate('/payment', { replace: true });
+ } else if (!isLoading && config?.paymentMethodCollection === 'disabled') {
+ navigate('/request/application', { replace: true });
+ }
+ }, [
+ config?.bookingMode,
+ config?.paymentMethodCollection,
+ isLoading,
+ navigate,
+ ]);
+
+ const setupMutation = useRequestPaymentSetup();
+
+ const applicationId = flow.requestIdempotencyKey;
+ const idempotencyKey = flow.requestPaymentSetupKey;
+ useEffect(() => {
+ if (!applicationId) flow.ensureRequestIdempotencyKey();
+ if (!idempotencyKey) flow.ensureRequestPaymentSetupKey();
+ }, [applicationId, flow, idempotencyKey]);
+
+ useEffect(() => {
+ if (
+ !collectCard ||
+ !cardCollectionAvailable ||
+ !applicationId ||
+ !idempotencyKey ||
+ !flow.guest?.email ||
+ setupMutation.isPending ||
+ setupMutation.isSuccess ||
+ setupMutation.isError
+ ) {
+ return;
+ }
+ setupMutation.mutate({
+ guestEmail: flow.guest.email,
+ idempotencyKey,
+ applicationId,
+ });
+ }, [
+ cardCollectionAvailable,
+ collectCard,
+ applicationId,
+ flow.guest?.email,
+ idempotencyKey,
+ setupMutation,
+ ]);
+
+ const stripePromise = useMemo(
+ () =>
+ collectCard && setupMutation.data?.clientMode === 'stripe' && publishableKey
+ ? loadStripe(publishableKey)
+ : null,
+ [collectCard, publishableKey, setupMutation.data?.clientMode],
+ );
+
+ if (
+ isLoading ||
+ config?.bookingMode !== 'request' ||
+ config.paymentMethodCollection === 'disabled' ||
+ !flow.criteria ||
+ !flow.roomType ||
+ !flow.rate ||
+ !flow.quote ||
+ !flow.guest
+ ) {
+ return null;
+ }
+
+ const submitRequest = (
+ card?: { setupIntentId: string; consentText: string },
+ ) => {
+ const stableKey = flow.requestIdempotencyKey ?? flow.ensureRequestIdempotencyKey();
+ void flow
+ .submitRequest(
+ requestPayload(
+ {
+ idempotencyKey: stableKey,
+ criteria: flow.criteria!,
+ roomType: flow.roomType!,
+ rate: flow.rate!,
+ guest: flow.guest!,
+ serviceIds: flow.serviceIds,
+ applicationAnswers: flow.applicationAnswers,
+ },
+ card
+ ? {
+ ...card,
+ consentVersion: REQUEST_CARD_CONSENT_VERSION,
+ }
+ : undefined,
+ ),
+ )
+ .catch(() => undefined);
+ };
+
+ const propertyName = config.displayName?.trim() || 'the hotel';
+ const setup = setupMutation.data;
+ const pageError = setupMutation.isError
+ ? errorMessage(setupMutation.error)
+ : flow.requestSubmissionStatus === 'error'
+ ? errorMessage(flow.requestSubmissionError)
+ : undefined;
+ const isSubmitting = flow.requestSubmissionStatus === 'pending';
+ const skipCard = () => {
+ setupMutation.reset();
+ setOptionalCardSelected(false);
+ submitRequest();
+ };
+ const retrySetup = () => {
+ if (!applicationId || !idempotencyKey || !flow.guest?.email) return;
+ setupMutation.reset();
+ setupMutation.mutate({
+ guestEmail: flow.guest.email,
+ idempotencyKey,
+ applicationId,
+ });
+ };
+
+ return (
+
+ navigate('/request/application')}
+ disabled={isSubmitting}
+ >
+ ← Back to your details
+
+
+
+
+ {config.paymentMethodCollection === 'optional' && !collectCard ? (
+
+
+ Add a payment method?
+
+
+ Adding a card can help the hotel process an approved request. It is
+ optional and nothing is charged when you submit.
+
+ {pageError && (
+
+ {pageError}
+
+ )}
+
+ {cardCollectionAvailable && (
+ setOptionalCardSelected(true)}
+ disabled={isSubmitting}
+ >
+ Add a card
+
+ )}
+
+ {isSubmitting
+ ? 'Submitting request…'
+ : 'Continue without a card'}
+
+
+
+ ) : flow.setupIntentId ? (
+
+
+ Payment method securely saved
+
+
+ No charge has been made. Submit your request for hotel review.
+
+ {pageError && (
+
+ {pageError}
+
+ )}
+
+ submitRequest({
+ setupIntentId: flow.setupIntentId!,
+ consentText: flow.setupIntentConsentText!,
+ })
+ }
+ disabled={isSubmitting}
+ >
+ {isSubmitting
+ ? 'Submitting request…'
+ : 'Submit booking request'}
+
+
+ ) : setupMutation.isPending || !idempotencyKey ? (
+
Preparing secure card entry…
+ ) : setupMutation.isError ? (
+
+
+ {errorMessage(setupMutation.error)}
+
+
+
+ Retry secure card entry
+
+ {config.paymentMethodCollection === 'optional' && (
+
+ {isSubmitting
+ ? 'Submitting request…'
+ : 'Continue without a card'}
+
+ )}
+
+
+ ) : setup?.clientMode === 'mock' ? (
+
{
+ flow.setSetupIntentId(setupIntentId);
+ flow.setSetupIntentConsentText(consentText);
+ submitRequest({ setupIntentId, consentText });
+ }}
+ />
+ ) : setup && stripePromise ? (
+
+ {
+ flow.setSetupIntentId(setupIntentId);
+ flow.setSetupIntentConsentText(consentText);
+ submitRequest({ setupIntentId, consentText });
+ }}
+ />
+
+ ) : (
+
+ Secure card collection is unavailable. Please contact the hotel.
+
+ )}
+
+ {pageError && collectCard && !setupMutation.isError && !flow.setupIntentId && (
+
+ {pageError}
+
+ )}
+
+
+ );
+}
diff --git a/apps/booking/src/pages/RequestReceived.tsx b/apps/booking/src/pages/RequestReceived.tsx
new file mode 100644
index 00000000..8ca836e0
--- /dev/null
+++ b/apps/booking/src/pages/RequestReceived.tsx
@@ -0,0 +1,53 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Button } from '../components/Button';
+import { useBookingFlow } from '../context/BookingFlowContext';
+
+export function RequestReceived() {
+ const navigate = useNavigate();
+ const { guest, requestAcknowledgement, reset } = useBookingFlow();
+
+ useEffect(() => {
+ if (!requestAcknowledgement) navigate('/', { replace: true });
+ }, [navigate, requestAcknowledgement]);
+
+ if (!requestAcknowledgement) return null;
+
+ return (
+
+
+
+ Request received · Pending review
+
+
+ The hotel will review your stay
+
+
+ {requestAcknowledgement.message} This is not a confirmed reservation.
+
+
+
+
+
What happens next
+
+ The hotel will review availability, your details, and the quoted stay. A
+ response will be sent by email{guest?.email ? ` to ${guest.email}` : ''}.
+
+
+ You have not been charged and submitting this request did not create a
+ booking.
+
+
+
+
{
+ reset();
+ navigate('/');
+ }}
+ >
+ Start a new search
+
+
+ );
+}
diff --git a/apps/booking/vitest.config.ts b/apps/booking/vitest.config.ts
index 1c718fa0..e6118e05 100644
--- a/apps/booking/vitest.config.ts
+++ b/apps/booking/vitest.config.ts
@@ -4,6 +4,9 @@ import path from 'path';
export default defineConfig({
plugins: [react()],
+ define: {
+ 'import.meta.env.VITE_HAIP_BOOKING_REQUESTS': JSON.stringify(process.env.VITE_HAIP_BOOKING_REQUESTS ?? 'true'),
+ },
test: {
environment: 'jsdom',
globals: true,
diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx
index f0e76b1a..1fabe458 100644
--- a/apps/dashboard/src/App.tsx
+++ b/apps/dashboard/src/App.tsx
@@ -4,6 +4,7 @@ import AppLayout from './components/layout/AppLayout';
import { ErrorBoundary } from './components/ui/ErrorBoundary';
import { SkeletonPage } from './components/ui/Skeleton';
import { useRealtimeInvalidation } from './hooks/useRealtimeInvalidation';
+import { isBookingRequestsUiEnabled } from './lib/bookingRequestsFeature';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const FrontDesk = lazy(() => import('./pages/FrontDesk'));
@@ -28,6 +29,7 @@ const Cashier = lazy(() => import('./pages/Cashier'));
const HouseAccounts = lazy(() => import('./pages/HouseAccounts'));
const Accounting = lazy(() => import('./pages/Accounting'));
const TaxSettings = lazy(() => import('./pages/TaxSettings'));
+const BookingRequests = lazy(() => import('./pages/BookingRequests'));
export default function App() {
useRealtimeInvalidation();
@@ -39,6 +41,9 @@ export default function App() {
} />
} />
+ {isBookingRequestsUiEnabled() && (
+ } />
+ )}
} />
} />
} />
diff --git a/apps/dashboard/src/components/admin/BookingEngineSettings.tsx b/apps/dashboard/src/components/admin/BookingEngineSettings.tsx
index 87cdc03c..47dc7d12 100644
--- a/apps/dashboard/src/components/admin/BookingEngineSettings.tsx
+++ b/apps/dashboard/src/components/admin/BookingEngineSettings.tsx
@@ -1,12 +1,22 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { KeyRound, Copy, Check, Trash2, Image as ImageIcon } from 'lucide-react';
+import { AlertTriangle, Check, Copy, Image as ImageIcon, KeyRound, RefreshCw, Trash2 } from 'lucide-react';
import { api } from '../../lib/api';
+import { isBookingRequestsUiEnabled } from '../../lib/bookingRequestsFeature';
import { useToast } from '../ui/Toast';
import MediaGallery from '../media/MediaGallery';
+import BookingQuestionBuilder from './BookingQuestionBuilder';
+import {
+ bookingQuestionsAreValid,
+ hasActiveUnsupportedQuestions,
+ type BookingFormQuestionDefinition,
+} from './booking-request-config';
type DepositType = 'none' | 'first_night' | 'percentage' | 'full';
+type BookingMode = 'instant' | 'request';
+type PaymentMethodCollection = 'required' | 'optional' | 'disabled';
+type PaymentMethodClientMode = 'mock' | 'stripe' | 'unsupported';
interface DepositPolicy {
type: DepositType;
@@ -27,6 +37,11 @@ interface BookingEngineConfig {
depositPolicy: DepositPolicy;
autoConfirm: boolean;
stripePublishableKey: string | null;
+ bookingMode?: BookingMode;
+ paymentMethodCollection?: PaymentMethodCollection;
+ paymentMethodClientMode?: PaymentMethodClientMode;
+ formQuestions?: BookingFormQuestionDefinition[];
+ updatedAt: string;
}
interface PublishableKey {
@@ -39,128 +54,274 @@ interface PublishableKey {
revokedAt: string | null;
}
-interface RoomType {
- id: string;
- name: string;
- code: string;
+interface RoomType { id: string; name: string; code: string }
+interface RatePlan { id: string; name: string; code: string }
+
+interface BookingEngineFormState {
+ isEnabled: boolean;
+ displayName: string;
+ logoMediaId: string | null;
+ primaryColor: string;
+ accentColor: string;
+ sellableRoomTypeIds: string[];
+ sellableRatePlanIds: string[];
+ depositPolicy: DepositPolicy;
+ autoConfirm: boolean;
+ stripePublishableKey: string;
+ bookingMode: BookingMode;
+ paymentMethodCollection: PaymentMethodCollection;
+ formQuestions: BookingFormQuestionDefinition[];
}
-interface RatePlan {
- id: string;
- name: string;
- code: string;
+type UpdateBookingEngineForm = (
+ update: (current: BookingEngineFormState) => BookingEngineFormState,
+) => void;
+
+interface BookingEngineUpdatePayload {
+ isEnabled?: boolean;
+ displayName?: string | null;
+ logoMediaId?: string | null;
+ primaryColor?: string;
+ accentColor?: string;
+ sellableRoomTypeIds?: string[];
+ sellableRatePlanIds?: string[];
+ depositPolicy?: DepositPolicy;
+ autoConfirm?: boolean;
+ stripePublishableKey?: string | null;
+ bookingMode?: BookingMode;
+ paymentMethodCollection?: PaymentMethodCollection;
+ formQuestions?: BookingFormQuestionDefinition[];
}
const DEFAULT_DEPOSIT: DepositPolicy = { type: 'none', refundable: true };
+const DEFAULT_FORM: BookingEngineFormState = {
+ isEnabled: false,
+ displayName: '',
+ logoMediaId: null,
+ primaryColor: '#0d9488',
+ accentColor: '#f97316',
+ sellableRoomTypeIds: [],
+ sellableRatePlanIds: [],
+ depositPolicy: DEFAULT_DEPOSIT,
+ autoConfirm: false,
+ stripePublishableKey: '',
+ bookingMode: 'instant',
+ paymentMethodCollection: 'disabled',
+ formQuestions: [],
+};
+
+function cloneQuestions(questions: BookingFormQuestionDefinition[]) {
+ return questions.map((question) => ({
+ ...question,
+ ...(Array.isArray(question.options) ? { options: [...question.options] } : {}),
+ }));
+}
+
+function formFromConfig(config: BookingEngineConfig): BookingEngineFormState {
+ return {
+ isEnabled: config.isEnabled ?? false,
+ displayName: config.displayName ?? '',
+ logoMediaId: config.logoMediaId ?? null,
+ primaryColor: config.primaryColor ?? '#0d9488',
+ accentColor: config.accentColor ?? '#f97316',
+ sellableRoomTypeIds: [...(config.sellableRoomTypeIds ?? [])],
+ sellableRatePlanIds: [...(config.sellableRatePlanIds ?? [])],
+ depositPolicy: { ...(config.depositPolicy ?? DEFAULT_DEPOSIT) },
+ autoConfirm: config.autoConfirm ?? false,
+ stripePublishableKey: config.stripePublishableKey ?? '',
+ bookingMode: config.bookingMode ?? 'instant',
+ paymentMethodCollection: config.paymentMethodCollection ?? 'disabled',
+ formQuestions: cloneQuestions(config.formQuestions ?? []),
+ };
+}
+
+function cloneForm(form: BookingEngineFormState): BookingEngineFormState {
+ return {
+ ...form,
+ sellableRoomTypeIds: [...form.sellableRoomTypeIds],
+ sellableRatePlanIds: [...form.sellableRatePlanIds],
+ depositPolicy: { ...form.depositPolicy },
+ formQuestions: cloneQuestions(form.formQuestions),
+ };
+}
+
+function normalizeQuestionOrder(questions: BookingFormQuestionDefinition[]) {
+ return questions
+ .map((question, index) => ({ question, index }))
+ .sort((left, right) => left.question.order - right.question.order || left.index - right.index)
+ .map(({ question }, order) => ({ ...question, order }));
+}
+
+function payloadFromChanges(
+ form: BookingEngineFormState,
+ baseline: BookingEngineFormState,
+): BookingEngineUpdatePayload {
+ const payload: BookingEngineUpdatePayload = {};
+ if (form.isEnabled !== baseline.isEnabled) payload.isEnabled = form.isEnabled;
+ if (form.displayName !== baseline.displayName) payload.displayName = form.displayName || null;
+ if (form.logoMediaId !== baseline.logoMediaId) payload.logoMediaId = form.logoMediaId;
+ if (form.primaryColor !== baseline.primaryColor) payload.primaryColor = form.primaryColor;
+ if (form.accentColor !== baseline.accentColor) payload.accentColor = form.accentColor;
+ if (JSON.stringify(form.sellableRoomTypeIds) !== JSON.stringify(baseline.sellableRoomTypeIds)) {
+ payload.sellableRoomTypeIds = [...form.sellableRoomTypeIds];
+ }
+ if (JSON.stringify(form.sellableRatePlanIds) !== JSON.stringify(baseline.sellableRatePlanIds)) {
+ payload.sellableRatePlanIds = [...form.sellableRatePlanIds];
+ }
+ if (JSON.stringify(form.depositPolicy) !== JSON.stringify(baseline.depositPolicy)) {
+ payload.depositPolicy = { ...form.depositPolicy };
+ }
+ if (form.autoConfirm !== baseline.autoConfirm) payload.autoConfirm = form.autoConfirm;
+ if (form.stripePublishableKey !== baseline.stripePublishableKey) {
+ payload.stripePublishableKey = form.stripePublishableKey.trim() || null;
+ }
+ if (form.bookingMode !== baseline.bookingMode) payload.bookingMode = form.bookingMode;
+ if (form.paymentMethodCollection !== baseline.paymentMethodCollection) {
+ payload.paymentMethodCollection = form.paymentMethodCollection;
+ }
+ if (JSON.stringify(form.formQuestions) !== JSON.stringify(baseline.formQuestions)) {
+ payload.formQuestions = normalizeQuestionOrder(cloneQuestions(form.formQuestions));
+ }
+ return payload;
+}
+
+function formsEqual(left: BookingEngineFormState, right: BookingEngineFormState) {
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+
+function toggleId(list: string[], id: string) {
+ return list.includes(id) ? list.filter((item) => item !== id) : [...list, id];
+}
export default function BookingEngineSettings({ propertyId }: { propertyId: string }) {
+ return ;
+}
+
+function BookingEngineSettingsForProperty({ propertyId }: { propertyId: string }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { toast } = useToast();
-
- const { data: configData } = useQuery({
+ const configQuery = useQuery({
queryKey: ['booking-engine', 'config', propertyId],
- queryFn: () => api.get('/v1/admin/booking-engine/config', { params: { propertyId } }).then((r) => r.data),
+ queryFn: () => api.get('/v1/admin/booking-engine/config', { params: { propertyId } }).then((response) => response.data),
enabled: !!propertyId,
});
-
+ const configData = configQuery.data;
const config: BookingEngineConfig | undefined = configData?.data ?? configData;
+ const [form, setForm] = useState(DEFAULT_FORM);
+ const [baseline, setBaseline] = useState(null);
+ const formRef = useRef(form);
+ const baselineRef = useRef(null);
+ const loadedPropertyRef = useRef(null);
+ const configVersionRef = useRef(null);
+ const [configVersion, setConfigVersion] = useState(null);
+ const [hasConflict, setHasConflict] = useState(false);
+ const [isQuestionEditorOpen, setIsQuestionEditorOpen] = useState(false);
+ const [questionBuilderKey, setQuestionBuilderKey] = useState(0);
- // ---- Editable form state ----
- const [isEnabled, setIsEnabled] = useState(false);
- const [displayName, setDisplayName] = useState('');
- const [logoMediaId, setLogoMediaId] = useState(null);
- const [primaryColor, setPrimaryColor] = useState('#0d9488');
- const [accentColor, setAccentColor] = useState('#f97316');
- const [sellableRoomTypeIds, setSellableRoomTypeIds] = useState([]);
- const [sellableRatePlanIds, setSellableRatePlanIds] = useState([]);
- const [depositPolicy, setDepositPolicy] = useState(DEFAULT_DEPOSIT);
- const [autoConfirm, setAutoConfirm] = useState(false);
-
- useEffect(() => {
- if (config) {
- setIsEnabled(config.isEnabled ?? false);
- setDisplayName(config.displayName ?? '');
- setLogoMediaId(config.logoMediaId ?? null);
- setPrimaryColor(config.primaryColor ?? '#0d9488');
- setAccentColor(config.accentColor ?? '#f97316');
- setSellableRoomTypeIds(config.sellableRoomTypeIds ?? []);
- setSellableRatePlanIds(config.sellableRatePlanIds ?? []);
- setDepositPolicy(config.depositPolicy ?? DEFAULT_DEPOSIT);
- setAutoConfirm(config.autoConfirm ?? false);
+ const syncForm = useCallback((next: BookingEngineFormState, version?: string | null) => {
+ const cloned = cloneForm(next);
+ formRef.current = cloned;
+ baselineRef.current = cloneForm(cloned);
+ loadedPropertyRef.current = propertyId;
+ if (version) {
+ configVersionRef.current = version;
+ setConfigVersion(version);
}
- }, [config]);
+ setForm(cloned);
+ setBaseline(cloneForm(cloned));
+ setHasConflict(false);
+ }, [propertyId]);
+
+ const updateForm = useCallback((update: (current: BookingEngineFormState) => BookingEngineFormState) => {
+ const next = update(formRef.current);
+ formRef.current = next;
+ setForm(next);
+ }, []);
+
+ if (config && loadedPropertyRef.current !== propertyId) {
+ syncForm(formFromConfig(config), config.updatedAt);
+ }
- // ---- Inventory sources ----
const { data: typesData } = useQuery({
queryKey: ['rooms', 'types', propertyId],
- queryFn: () => api.get('/v1/rooms/types', { params: { propertyId } }).then((r) => r.data),
+ queryFn: () => api.get('/v1/rooms/types', { params: { propertyId } }).then((response) => response.data),
enabled: !!propertyId,
});
const { data: ratePlansData } = useQuery({
queryKey: ['rate-plans', propertyId],
- queryFn: () => api.get('/v1/rate-plans', { params: { propertyId } }).then((r) => r.data),
+ queryFn: () => api.get('/v1/rate-plans', { params: { propertyId } }).then((response) => response.data),
enabled: !!propertyId,
});
-
const roomTypes: RoomType[] = typesData?.data ?? typesData ?? [];
const ratePlans: RatePlan[] = ratePlansData?.data ?? ratePlansData ?? [];
-
- // ---- Publishable keys ----
const { data: keysData } = useQuery({
queryKey: ['booking-engine', 'keys', propertyId],
- queryFn: () => api.get('/v1/admin/booking-engine/keys', { params: { propertyId } }).then((r) => r.data),
+ queryFn: () => api.get('/v1/admin/booking-engine/keys', { params: { propertyId } }).then((response) => response.data),
enabled: !!propertyId,
});
const keys: PublishableKey[] = keysData?.data ?? keysData ?? [];
-
const [newKey, setNewKey] = useState(null);
const [copied, setCopied] = useState(false);
const createKey = useMutation({
- mutationFn: (label: string) =>
- api.post('/v1/admin/booking-engine/keys', { label }, { params: { propertyId } }).then((r) => r.data),
- onSuccess: (res) => {
- const created = res?.data ?? res;
+ mutationFn: (label: string) => api.post('/v1/admin/booking-engine/keys', { label }, { params: { propertyId } }).then((response) => response.data),
+ onSuccess: (result) => {
+ const created = result?.data ?? result;
setNewKey(created?.key ?? null);
setCopied(false);
- queryClient.invalidateQueries({ queryKey: ['booking-engine', 'keys'] });
+ queryClient.invalidateQueries({ queryKey: ['booking-engine', 'keys', propertyId] });
toast('success', t('bookingEngine.toasts.keyGenerated'));
},
+ onError: () => toast('error', t('bookingEngine.toasts.keyGenerationFailed')),
});
-
const revokeKey = useMutation({
mutationFn: (id: string) => api.delete(`/v1/admin/booking-engine/keys/${id}`, { params: { propertyId } }),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['booking-engine', 'keys'] });
+ queryClient.invalidateQueries({ queryKey: ['booking-engine', 'keys', propertyId] });
toast('success', t('bookingEngine.toasts.keyRevoked'));
},
+ onError: () => toast('error', t('bookingEngine.toasts.keyRevocationFailed')),
});
-
const saveConfig = useMutation({
- mutationFn: () =>
- api.patch('/v1/admin/booking-engine/config', {
- isEnabled,
- displayName: displayName || null,
- logoMediaId,
- primaryColor,
- accentColor,
- sellableRoomTypeIds,
- sellableRatePlanIds,
- depositPolicy,
- autoConfirm,
- }, { params: { propertyId } }),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['booking-engine', 'config'] });
+ mutationFn: ({ payload, expectedUpdatedAt }: {
+ payload: BookingEngineUpdatePayload;
+ savedForm: BookingEngineFormState;
+ expectedUpdatedAt: string;
+ }) => api.patch(
+ '/v1/admin/booking-engine/config',
+ payload,
+ {
+ params: { propertyId },
+ headers: { 'If-Match': `"${expectedUpdatedAt}"` },
+ },
+ ),
+ onSuccess: (result, variables) => {
+ const responseBody = result.data?.data ?? result.data;
+ const savedConfig = responseBody?.updatedAt
+ ? responseBody as BookingEngineConfig
+ : undefined;
+ syncForm(
+ savedConfig ? formFromConfig(savedConfig) : variables.savedForm,
+ savedConfig?.updatedAt ?? configVersionRef.current,
+ );
+ queryClient.invalidateQueries({ queryKey: ['booking-engine', 'config', propertyId] });
toast('success', t('bookingEngine.toasts.settingsSaved'));
},
+ onError: (error: unknown) => {
+ const status = (error as { response?: { status?: number } }).response?.status;
+ if (status === 409) {
+ setHasConflict(true);
+ return;
+ }
+ toast('error', t('bookingEngine.toasts.settingsSaveFailed'));
+ },
});
const generateKey = () => {
const label = window.prompt(t('bookingEngine.promptLabel'));
- if (label && label.trim()) createKey.mutate(label.trim());
+ if (label?.trim()) createKey.mutate(label.trim());
};
-
const copyKey = () => {
if (!newKey) return;
navigator.clipboard.writeText(newKey).then(() => {
@@ -168,263 +329,356 @@ export default function BookingEngineSettings({ propertyId }: { propertyId: stri
setTimeout(() => setCopied(false), 2000);
});
};
+ const formDirty = baseline !== null && !formsEqual(form, baseline);
+ const dirty = formDirty || isQuestionEditorOpen;
+ const cardCollectionEnabled = form.bookingMode === 'request'
+ && form.paymentMethodCollection !== 'disabled';
+ const paymentMethodClientMode = config?.paymentMethodClientMode ?? 'stripe';
+ const missingStripeCardKey = form.bookingMode === 'request'
+ && cardCollectionEnabled
+ && paymentMethodClientMode === 'stripe'
+ && form.stripePublishableKey.trim().length === 0;
+ const unsupportedCardCollection = cardCollectionEnabled
+ && paymentMethodClientMode === 'unsupported';
+ const cardCollectionBlocked = missingStripeCardKey || unsupportedCardCollection;
+ const questionsValid = bookingQuestionsAreValid(form.formQuestions);
+ const questionsChanged = baseline !== null
+ && JSON.stringify(form.formQuestions) !== JSON.stringify(baseline.formQuestions);
+ const unsupportedActive = hasActiveUnsupportedQuestions(form.formQuestions);
+ const questionsPublishBlocked = questionsChanged && unsupportedActive;
+ const save = () => {
+ if (!formDirty || isQuestionEditorOpen || !baselineRef.current || !configVersionRef.current || hasConflict || cardCollectionBlocked || !questionsValid || questionsPublishBlocked || saveConfig.isPending) return;
+ const savedForm = cloneForm(formRef.current);
+ saveConfig.mutate({
+ payload: payloadFromChanges(savedForm, baselineRef.current),
+ savedForm,
+ expectedUpdatedAt: configVersionRef.current,
+ });
+ };
- const toggleId = (list: string[], id: string) =>
- list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
+ const reloadLatest = async () => {
+ const result = await configQuery.refetch();
+ const latestData = result.data;
+ const latest: BookingEngineConfig | undefined = latestData?.data ?? latestData;
+ if (result.isSuccess && latest) {
+ setQuestionBuilderKey((key) => key + 1);
+ setIsQuestionEditorOpen(false);
+ saveConfig.reset();
+ syncForm(formFromConfig(latest), latest.updatedAt);
+ }
+ };
- return (
-
- {/* 1. Enable toggle */}
-
-
+ const reset = () => {
+ if (!baseline) return;
+ setQuestionBuilderKey((key) => key + 1);
+ setIsQuestionEditorOpen(false);
+ saveConfig.reset();
+ syncForm(baseline, configVersionRef.current);
+ };
+
+ if (!config) {
+ if (configQuery.isLoading) {
+ return
{t('bookingEngine.requestSettings.loading')}
;
+ }
+ return (
+
+
+
-
{t('bookingEngine.title')}
-
- {t('bookingEngine.description')}
-
-
-
- {isEnabled ? t('bookingEngine.enabled') : t('bookingEngine.disabled')}
- setIsEnabled((v) => !v)}
- className={`relative w-11 h-6 rounded-full transition-colors ${isEnabled ? 'bg-telivity-teal' : 'bg-gray-300'}`}
- >
-
+ {t('bookingEngine.requestSettings.loadError')}
+ configQuery.refetch()} className="inline-flex items-center gap-1.5 mt-3 text-sm font-semibold text-telivity-deep-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue rounded">
+ {t('bookingEngine.requestSettings.retry')}
-
+
+ );
+ }
+ if (configQuery.isLoading || loadedPropertyRef.current !== propertyId) {
+ return
{t('bookingEngine.requestSettings.loading')}
;
+ }
- {/* 2. Publishable keys */}
-
-
-
{t('bookingEngine.publishableKeys')}
-
- {t('bookingEngine.generateKey')}
+ return (
+
+ {configQuery.isError && (
+
+
{t('bookingEngine.requestSettings.backgroundLoadError')}
+
configQuery.refetch()} className="inline-flex items-center gap-1.5 text-sm font-semibold text-telivity-deep-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue rounded">
+ {t('bookingEngine.requestSettings.retry')}
-
- {newKey && (
-
-
- {t('bookingEngine.copyKeyWarning')}
-
-
-
- {newKey}
-
-
- {copied ? : } {copied ? t('bookingEngine.copied') : t('bookingEngine.copy')}
-
-
setNewKey(null)}
- className="text-xs text-telivity-mid-grey hover:text-telivity-slate shrink-0"
- >
- {t('bookingEngine.dismiss')}
-
+ )}
+ {hasConflict && (
+
+
{t('bookingEngine.requestSettings.conflictTitle')}
+
{t('bookingEngine.requestSettings.conflictDescription')}
+
+ {t('bookingEngine.requestSettings.reloadLatest')}
+
+
+ )}
+
+
+
+
+
{t('bookingEngine.title')}
+
{t('bookingEngine.description')}
+
+ {form.isEnabled ? t('bookingEngine.enabled') : t('bookingEngine.disabled')}
+ updateForm((current) => ({ ...current, isEnabled: !current.isEnabled }))} className={`relative w-11 h-6 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue focus-visible:ring-offset-2 motion-reduce:transition-none ${form.isEnabled ? 'bg-telivity-deep-blue' : 'bg-telivity-slate'}`}>
+
+
+
- )}
-
-
-
-
- {t('bookingEngine.label')}
- {t('bookingEngine.key')}
- {t('common.status')}
- {t('bookingEngine.created')}
- {t('common.actions')}
-
-
-
- {keys.map((k, i) => (
-
- {k.label}
- {k.keyPrefix}••••
-
- {k.isActive ? t('bookingEngine.active') : t('bookingEngine.revoked')}
-
-
- {k.createdAt ? new Date(k.createdAt).toLocaleDateString() : '—'}
-
-
- {k.isActive && (
- revokeKey.mutate(k.id)}
- disabled={revokeKey.isPending}
- className="inline-flex items-center gap-1 text-red-500 hover:text-red-600 text-sm font-medium disabled:opacity-50"
- >
- {t('bookingEngine.revoke')}
-
+
+
+
{t('bookingEngine.requestSettings.title')}
+
{t('bookingEngine.requestSettings.description')}
+
+
+ {t('bookingEngine.requestSettings.bookingMode')}
+ updateForm((current) => ({ ...current, bookingMode: event.target.value as BookingMode }))} className="w-full border border-telivity-slate rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-deep-blue focus-visible:ring-2 focus-visible:ring-telivity-deep-blue">
+ {t('bookingEngine.requestSettings.modes.instant')}
+ {/* Request mode requires the API to run with HAIP_BOOKING_REQUESTS=true
+ (see booking-engine-config.service.ts). Hidden unless this build opted
+ in, or the property is already configured for it (opaque preservation,
+ matching the unsupported-question pattern above). */}
+ {(isBookingRequestsUiEnabled() || form.bookingMode === 'request') && (
+ {t('bookingEngine.requestSettings.modes.request')}
)}
-
-
- ))}
- {keys.length === 0 && (
- {t('bookingEngine.noKeysYet')}
+
+ {t(`bookingEngine.requestSettings.modeDescriptions.${form.bookingMode}`)}
+
+
+
{t('bookingEngine.requestSettings.cardCollection')}
+
updateForm((current) => ({ ...current, paymentMethodCollection: event.target.value as PaymentMethodCollection }))} className="w-full border border-telivity-slate rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-deep-blue focus-visible:ring-2 focus-visible:ring-telivity-deep-blue">
+ {t('bookingEngine.requestSettings.cardPolicies.disabled')}
+ {t('bookingEngine.requestSettings.cardPolicies.optional')}
+ {t('bookingEngine.requestSettings.cardPolicies.required')}
+
+
{t(`bookingEngine.requestSettings.cardDescriptions.${form.paymentMethodCollection}`)}
+
+
+
{t('bookingEngine.requestSettings.stripeKey')}
+
updateForm((current) => ({ ...current, stripePublishableKey: event.target.value }))} placeholder={t('bookingEngine.requestSettings.stripeKeyPlaceholder')} autoComplete="off" className="w-full border border-telivity-slate rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:border-telivity-deep-blue focus-visible:ring-2 focus-visible:ring-telivity-deep-blue" />
+
{t('bookingEngine.requestSettings.stripeKeyDescription')}
+
+
+ {cardCollectionBlocked && (
+
+
+
+ {t(unsupportedCardCollection
+ ? 'bookingEngine.requestSettings.unsupportedCardWarning'
+ : 'bookingEngine.requestSettings.requiredCardWarning')}
+
+
)}
-
-
+
+
+
+
updateForm((current) => ({ ...current, formQuestions }))} onEditorOpenChange={setIsQuestionEditorOpen} disabled={saveConfig.isPending} />
+ {!questionsValid && {t('bookingEngine.requestSettings.invalidQuestions')}
}
+ {questionsPublishBlocked && {t('bookingEngine.requestSettings.unsupportedPublishBlocked')}
}
+
+
+
+
+
+
+
{saveConfig.isPending ? t('bookingEngine.requestSettings.saving') : t('bookingEngine.requestSettings.save')}
+
{t('bookingEngine.requestSettings.reset')}
+ {dirty &&
{t('bookingEngine.requestSettings.unsaved')} }
+ {saveConfig.isError && !hasConflict &&
{t('bookingEngine.requestSettings.saveError')}
}
+
+
+
+ setNewKey(null)}
+ onRevoke={(id) => revokeKey.mutate(id)}
+ />
+
+ );
+}
+
+function SellableInventory({
+ roomTypes,
+ ratePlans,
+ form,
+ updateForm,
+}: {
+ roomTypes: RoomType[];
+ ratePlans: RatePlan[];
+ form: BookingEngineFormState;
+ updateForm: UpdateBookingEngineForm;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
{t('bookingEngine.sellableInventory')}
+
{t('bookingEngine.sellableInventoryDescription')}
+
+ updateForm((current) => ({ ...current, sellableRoomTypeIds: toggleId(current.sellableRoomTypeIds, id) }))} />
+ updateForm((current) => ({ ...current, sellableRatePlanIds: toggleId(current.sellableRatePlanIds, id) }))} />
+
+ );
+}
- {/* 3. Sellable inventory */}
-
-
{t('bookingEngine.sellableInventory')}
-
{t('bookingEngine.sellableInventoryDescription')}
-
-
-
{t('bookingEngine.roomTypes')}
-
- {roomTypes.map((t) => (
-
- setSellableRoomTypeIds((s) => toggleId(s, t.id))}
- className="accent-telivity-teal"
- />
- {t.name}
- {t.code}
-
- ))}
- {roomTypes.length === 0 &&
{t('bookingEngine.noRoomTypes')}
}
-
-
-
-
{t('bookingEngine.ratePlans')}
-
- {ratePlans.map((p) => (
-
- setSellableRatePlanIds((s) => toggleId(s, p.id))}
- className="accent-telivity-teal"
- />
- {p.name}
- {p.code}
-
- ))}
- {ratePlans.length === 0 &&
{t('bookingEngine.noRatePlans')}
}
-
-
+function BrandingSettings({
+ propertyId,
+ form,
+ updateForm,
+}: {
+ propertyId: string;
+ form: BookingEngineFormState;
+ updateForm: UpdateBookingEngineForm;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
{t('bookingEngine.branding')}
+
+
+ {t('bookingEngine.displayName')}
+ updateForm((current) => ({ ...current, displayName: event.target.value }))} placeholder={t('bookingEngine.displayNamePlaceholder')} className="w-full border border-telivity-slate rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-deep-blue focus-visible:ring-2 focus-visible:ring-telivity-deep-blue" />
+
+
+ updateForm((current) => ({ ...current, primaryColor }))} />
+ updateForm((current) => ({ ...current, accentColor }))} />
+
+
+
{t('bookingEngine.logo')}
+
+
+ );
+}
- {/* 4. Branding */}
-
-
{t('bookingEngine.branding')}
-
+function DepositSettings({
+ form,
+ updateForm,
+}: {
+ form: BookingEngineFormState;
+ updateForm: UpdateBookingEngineForm;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
{t('bookingEngine.depositPolicy')}
+
+
- {t('bookingEngine.displayName')}
- setDisplayName(e.target.value)}
- placeholder={t('bookingEngine.displayNamePlaceholder')}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal"
- />
+ {t('bookingEngine.type')}
+ updateForm((current) => ({ ...current, depositPolicy: { ...current.depositPolicy, type: event.target.value as DepositType } }))} className="w-full border border-telivity-slate rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-deep-blue focus-visible:ring-2 focus-visible:ring-telivity-deep-blue">
+ {t('bookingEngine.depositTypes.none')}
+ {t('bookingEngine.depositTypes.first_night')}
+ {t('bookingEngine.depositTypes.percentage')}
+ {t('bookingEngine.depositTypes.full')}
+
-
+ {form.depositPolicy.type === 'percentage' && (
-
-
-
-
-
- {t('bookingEngine.logo')}
-
-
-
+ )}
+
updateForm((current) => ({ ...current, depositPolicy: { ...current.depositPolicy, refundable: event.target.checked } }))} className="accent-telivity-deep-blue" />{t('bookingEngine.refundableDeposit')}
+
updateForm((current) => ({ ...current, autoConfirm: event.target.checked }))} className="accent-telivity-deep-blue" />{t('bookingEngine.autoConfirm')}
+
{t('bookingEngine.requestSettings.autoConfirmDescription')}
+
+ );
+}
- {/* 5. Deposit policy */}
-
-
{t('bookingEngine.depositPolicy')}
-
-
-
- {t('bookingEngine.type')}
- setDepositPolicy((d) => ({ ...d, type: e.target.value as DepositType }))}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal"
- >
- {t('bookingEngine.depositTypes.none')}
- {t('bookingEngine.depositTypes.first_night')}
- {t('bookingEngine.depositTypes.percentage')}
- {t('bookingEngine.depositTypes.full')}
-
-
- {depositPolicy.type === 'percentage' && (
-
- {t('bookingEngine.percentageLabel')}
- setDepositPolicy((d) => ({ ...d, percentage: Number(e.target.value) }))}
- className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal"
- />
-
- )}
+function PublishableKeysPanel({
+ keys,
+ newKey,
+ copied,
+ generating,
+ revoking,
+ onGenerate,
+ onCopy,
+ onDismiss,
+ onRevoke,
+}: {
+ keys: PublishableKey[];
+ newKey: string | null;
+ copied: boolean;
+ generating: boolean;
+ revoking: boolean;
+ onGenerate: () => void;
+ onCopy: () => void;
+ onDismiss: () => void;
+ onRevoke: (id: string) => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+
{t('bookingEngine.publishableKeys')}
+ {t('bookingEngine.generateKey')}
+
+ {newKey && (
+
+
{t('bookingEngine.copyKeyWarning')}
+
+ {newKey}
+ {copied ? : } {copied ? t('bookingEngine.copied') : t('bookingEngine.copy')}
+ {t('bookingEngine.dismiss')}
-
- setDepositPolicy((d) => ({ ...d, refundable: e.target.checked }))}
- className="accent-telivity-teal"
- />
- {t('bookingEngine.refundableDeposit')}
-
-
- setAutoConfirm(e.target.checked)}
- className="accent-telivity-teal"
- />
- {t('bookingEngine.autoConfirm')}
-
+ )}
+
+
+ {t('bookingEngine.label')} {t('bookingEngine.key')} {t('common.status')} {t('bookingEngine.created')} {t('common.actions')}
+
+ {keys.map((key, index) => (
+ {key.label} {key.keyPrefix}•••• {key.isActive ? t('bookingEngine.active') : t('bookingEngine.revoked')} {key.createdAt ? new Date(key.createdAt).toLocaleDateString() : '—'} {key.isActive && onRevoke(key.id)} disabled={revoking} className="inline-flex items-center gap-1 text-red-700 hover:text-red-800 text-sm font-medium disabled:opacity-50"> {t('bookingEngine.revoke')} }
+ ))}
+ {keys.length === 0 && {t('bookingEngine.noKeysYet')} }
+
+
+
+ );
+}
+
+function InventoryList({ title, empty, items, selected, onToggle }: { title: string; empty: string; items: Array
; selected: string[]; onToggle: (id: string) => void }) {
+ const selectedIds = new Set(selected);
+ return (
+
+
{title}
+
+ {items.map((item) =>
onToggle(item.id)} className="accent-telivity-deep-blue" />{item.name} {item.code} )}
+ {items.length === 0 &&
{empty}
}
+
+
+ );
+}
- {/* 6. Save */}
-
-
saveConfig.mutate()}
- disabled={saveConfig.isPending}
- className="bg-telivity-teal text-white rounded-lg px-6 py-2 text-sm font-semibold disabled:opacity-50"
- >
- {saveConfig.isPending ? t('common.saving') : t('common.save')}
-
+function ColorControl({ id, label, value, onChange }: { id: string; label: string; value: string; onChange: (value: string) => void }) {
+ return (
+
);
diff --git a/apps/dashboard/src/components/admin/BookingQuestionBuilder.test.tsx b/apps/dashboard/src/components/admin/BookingQuestionBuilder.test.tsx
new file mode 100644
index 00000000..e8b3cac1
--- /dev/null
+++ b/apps/dashboard/src/components/admin/BookingQuestionBuilder.test.tsx
@@ -0,0 +1,846 @@
+import { useState } from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ToastProvider } from '../ui/Toast';
+import BookingQuestionBuilder from './BookingQuestionBuilder';
+import type {
+ BookingFormQuestion,
+ BookingFormQuestionDefinition,
+} from './booking-request-config';
+import BookingEngineSettings from './BookingEngineSettings';
+import en from '../../locales/en.json';
+import de from '../../locales/de.json';
+import es from '../../locales/es.json';
+import fr from '../../locales/fr.json';
+import hr from '../../locales/hr.json';
+import itMessages from '../../locales/it.json';
+import ptBR from '../../locales/pt-BR.json';
+import srLatn from '../../locales/sr-Latn.json';
+
+vi.mock('../../lib/api', () => ({
+ api: {
+ get: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ },
+}));
+
+vi.mock('../media/MediaGallery', () => ({ default: () => null }));
+
+import { api } from '../../lib/api';
+
+const FIRST_ID = '10000000-0000-4000-8000-000000000001';
+const SECOND_ID = '10000000-0000-4000-8000-000000000002';
+const THIRD_ID = '10000000-0000-4000-8000-000000000003';
+
+const arrivalQuestion: BookingFormQuestion = {
+ id: FIRST_ID,
+ label: 'Arrival time',
+ type: 'short_text',
+ order: 4,
+ isActive: true,
+ isRequired: true,
+};
+
+const breakfastQuestion: BookingFormQuestion = {
+ id: SECOND_ID,
+ label: 'Breakfast preference',
+ type: 'single_select',
+ options: ['Continental', 'Cooked'],
+ order: 9,
+ isActive: false,
+ isRequired: false,
+};
+
+function BuilderHarness({
+ initial = [],
+ idFactory,
+ onValue,
+}: {
+ initial?: BookingFormQuestionDefinition[];
+ idFactory?: () => string;
+ onValue?: (value: BookingFormQuestionDefinition[]) => void;
+}) {
+ const [questions, setQuestions] = useState
(initial);
+ return (
+ {
+ setQuestions(next);
+ onValue?.(next);
+ }}
+ />
+ );
+}
+
+describe('BookingQuestionBuilder', () => {
+ it('explains why a blank question cannot be saved', async () => {
+ render( FIRST_ID} />);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Add question' }));
+
+ expect(screen.getByRole('textbox', { name: 'Question label' })).toHaveAttribute('aria-invalid', 'true');
+ expect(screen.getByText('Enter a question label.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Save question' })).toBeDisabled();
+ });
+
+ it('adds one exact Task 3 question with a stable, collision-free UUID', async () => {
+ const changes: BookingFormQuestionDefinition[][] = [];
+ const idFactory = vi.fn()
+ .mockReturnValueOnce(FIRST_ID)
+ .mockReturnValueOnce(THIRD_ID);
+ render(
+ changes.push(questions)}
+ />,
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Add question' }));
+ await userEvent.type(screen.getByRole('textbox', { name: 'Question label' }), ' Travel purpose ');
+ await userEvent.click(screen.getByRole('button', { name: 'Save question' }));
+
+ expect(changes.at(-1)).toEqual([
+ { ...arrivalQuestion, order: 0 },
+ {
+ id: THIRD_ID,
+ label: 'Travel purpose',
+ type: 'short_text',
+ order: 1,
+ isActive: true,
+ isRequired: false,
+ },
+ ]);
+ expect(idFactory).toHaveBeenCalledTimes(2);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Edit Travel purpose' }));
+ await userEvent.clear(screen.getByRole('textbox', { name: 'Question label' }));
+ await userEvent.type(screen.getByRole('textbox', { name: 'Question label' }), 'Reason for stay');
+ await userEvent.click(screen.getByRole('button', { name: 'Save question' }));
+
+ expect(changes.at(-1)?.[1]).toMatchObject({
+ id: THIRD_ID,
+ label: 'Reason for stay',
+ order: 1,
+ });
+ expect(idFactory).toHaveBeenCalledTimes(2);
+ });
+
+ it('edits and orders select options, normalizes whitespace, and rejects blank or duplicate options', async () => {
+ const changes: BookingFormQuestionDefinition[][] = [];
+ render(
+ changes.push(questions)}
+ />,
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Edit Arrival time' }));
+ await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Question type' }), 'multi_select');
+ const firstOption = screen.getByRole('textbox', { name: 'Option 1' });
+ const optionsGroup = screen.getByRole('group', { name: 'Answer options' });
+ expect(optionsGroup.firstElementChild?.tagName).toBe('LEGEND');
+ await userEvent.type(firstOption, ' Vegan ');
+ await userEvent.click(screen.getByRole('button', { name: 'Add option' }));
+ await userEvent.type(screen.getByRole('textbox', { name: 'Option 2' }), 'vegan');
+
+ expect(screen.getByText('Options must be unique.')).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: 'Option 2' })).toHaveAttribute('aria-invalid', 'true');
+ expect(screen.getByRole('textbox', { name: 'Option 2' })).toHaveAttribute('aria-describedby');
+ expect(screen.getByRole('button', { name: 'Save question' })).toBeDisabled();
+
+ await userEvent.clear(screen.getByRole('textbox', { name: 'Option 2' }));
+ expect(screen.getByText('Options cannot be blank.')).toBeInTheDocument();
+ await userEvent.type(screen.getByRole('textbox', { name: 'Option 2' }), 'Gluten-free');
+ await userEvent.click(screen.getByRole('button', { name: 'Move option 2 up' }));
+ await userEvent.click(screen.getByRole('button', { name: 'Save question' }));
+
+ expect(changes.at(-1)?.[0]).toEqual({
+ ...arrivalQuestion,
+ order: 0,
+ type: 'multi_select',
+ options: ['Gluten-free', 'Vegan'],
+ });
+ });
+
+ it('removes select options when the type changes to a non-select type', async () => {
+ const changes: BookingFormQuestionDefinition[][] = [];
+ render(
+ changes.push(questions)}
+ />,
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Edit Breakfast preference' }));
+ await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Question type' }), 'date');
+
+ expect(screen.queryByRole('textbox', { name: 'Option 1' })).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole('button', { name: 'Save question' }));
+ expect(changes.at(-1)?.[0]).toEqual({
+ id: SECOND_ID,
+ label: 'Breakfast preference',
+ type: 'date',
+ order: 0,
+ isActive: false,
+ isRequired: false,
+ });
+ });
+
+ it('reorders without changing identity and disables without dropping historical definitions', async () => {
+ const changes: BookingFormQuestionDefinition[][] = [];
+ render(
+ changes.push(questions)}
+ />,
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Move Breakfast preference up' }));
+ expect(changes.at(-1)).toEqual([
+ { ...arrivalQuestion, order: 1 },
+ { ...breakfastQuestion, order: 0 },
+ ]);
+
+ await userEvent.click(screen.getByRole('switch', { name: 'Disable Arrival time' }));
+ expect(changes.at(-1)).toEqual([
+ { ...arrivalQuestion, order: 1, isActive: false },
+ { ...breakfastQuestion, order: 0 },
+ ]);
+ expect(screen.getAllByText('Inactive')).toHaveLength(2);
+ });
+
+ it('removes a question and normalizes survivor order', async () => {
+ const changes: BookingFormQuestionDefinition[][] = [];
+ render(
+ changes.push(questions)}
+ />,
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Remove Arrival time' }));
+ expect(changes.at(-1)).toEqual([{ ...breakfastQuestion, order: 0 }]);
+ });
+
+ it('locks list mutations while editing so saving cannot restore stale row state', async () => {
+ const changes: BookingFormQuestionDefinition[][] = [];
+ render( changes.push(value)} />);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Edit Arrival time' }));
+ expect(screen.getByRole('button', { name: 'Move Breakfast preference up' })).toBeDisabled();
+ expect(screen.getByRole('switch', { name: 'Enable Breakfast preference' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Remove Breakfast preference' })).toBeDisabled();
+
+ await userEvent.clear(screen.getByRole('textbox', { name: 'Question label' }));
+ await userEvent.type(screen.getByRole('textbox', { name: 'Question label' }), 'Arrival details');
+ await userEvent.click(screen.getByRole('button', { name: 'Save question' }));
+
+ expect(changes.at(-1)).toEqual([
+ { ...arrivalQuestion, label: 'Arrival details', order: 0 },
+ { ...breakfastQuestion, order: 1 },
+ ]);
+ });
+
+ it('focuses the editor and restores focus after cancel, save, and remove', async () => {
+ render( THIRD_ID} />);
+ const add = screen.getByRole('button', { name: 'Add question' });
+
+ await userEvent.click(add);
+ expect(screen.getByRole('textbox', { name: 'Question label' })).toHaveFocus();
+ await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ await waitFor(() => expect(add).toHaveFocus());
+
+ await userEvent.click(screen.getByRole('button', { name: 'Edit Arrival time' }));
+ const label = screen.getByRole('textbox', { name: 'Question label' });
+ expect(label).toHaveFocus();
+ await userEvent.clear(label);
+ await userEvent.type(label, 'Arrival details');
+ await userEvent.click(screen.getByRole('button', { name: 'Save question' }));
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Edit Arrival details' })).toHaveFocus());
+
+ await userEvent.click(screen.getByRole('button', { name: 'Remove Breakfast preference' }));
+ await waitFor(() => expect(add).toHaveFocus());
+ });
+
+ it('preserves an unsupported inactive definition opaquely and prevents destructive controls', async () => {
+ const futureQuestion: BookingFormQuestionDefinition = {
+ id: THIRD_ID,
+ label: 'Legacy satisfaction score',
+ type: 'rating_scale',
+ order: 12,
+ isActive: false,
+ isRequired: false,
+ options: ['1', '2', '3', '4', '5'],
+ futureConfig: { maximum: 5, icon: 'star' },
+ };
+ const changes: BookingFormQuestionDefinition[][] = [];
+ render( changes.push(value)} />);
+
+ expect(screen.getByText('Unsupported question')).toBeInTheDocument();
+ expect(screen.queryByText('bookingEngine.questions.types.rating_scale')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Edit Legacy satisfaction score' })).toBeDisabled();
+ expect(screen.getByRole('switch', { name: 'Enable Legacy satisfaction score' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Remove Legacy satisfaction score' })).toBeDisabled();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Edit Arrival time' }));
+ await userEvent.clear(screen.getByRole('textbox', { name: 'Question label' }));
+ await userEvent.type(screen.getByRole('textbox', { name: 'Question label' }), 'Arrival details');
+ await userEvent.click(screen.getByRole('button', { name: 'Save question' }));
+
+ expect(changes.at(-1)?.[1]).toEqual({
+ ...futureQuestion,
+ order: 1,
+ });
+ });
+
+ it('uses AA dashboard tokens for text, primary actions, and focus indicators', () => {
+ const { container } = render( );
+ const section = container.querySelector('section[aria-labelledby="guest-form-blueprint-title"]');
+
+ expect(section?.querySelectorAll('.text-telivity-mid-grey')).toHaveLength(0);
+ expect(section?.querySelectorAll('.border-gray-300')).toHaveLength(0);
+ expect(screen.getByRole('button', { name: 'Add question' })).toHaveClass('bg-telivity-deep-blue');
+ expect(screen.getByRole('button', { name: 'Add question' })).toHaveClass('focus-visible:ring-telivity-deep-blue');
+ });
+
+ it('restores focus to the newly added row when the fiftieth question disables Add', async () => {
+ const questions = Array.from({ length: 49 }, (_, index): BookingFormQuestion => ({
+ id: `10000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
+ label: `Question ${index + 1}`,
+ type: 'short_text',
+ order: index,
+ isActive: true,
+ isRequired: false,
+ }));
+ render( '10000000-0000-4000-8000-000000000999'}
+ />);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Add question' }));
+ await userEvent.type(screen.getByRole('textbox', { name: 'Question label' }), 'Final question');
+ await userEvent.click(screen.getByRole('button', { name: 'Save question' }));
+
+ expect(screen.getByRole('button', { name: 'Add question' })).toBeDisabled();
+ await waitFor(() => expect(
+ screen.getByRole('button', { name: 'Edit Final question' }),
+ ).toHaveFocus());
+ });
+
+ it('offers exactly the six approved question types and blocks a 51st question', async () => {
+ const questions = Array.from({ length: 50 }, (_, index): BookingFormQuestion => ({
+ id: `10000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
+ label: `Question ${index + 1}`,
+ type: 'short_text',
+ order: index,
+ isActive: true,
+ isRequired: false,
+ }));
+ const { rerender } = render( THIRD_ID} />);
+ await userEvent.click(screen.getByRole('button', { name: 'Add question' }));
+
+ const types = within(screen.getByRole('combobox', { name: 'Question type' }))
+ .getAllByRole('option')
+ .map((option) => ({ label: option.textContent, value: (option as HTMLOptionElement).value }));
+ expect(types).toEqual([
+ { label: 'Short text', value: 'short_text' },
+ { label: 'Long text', value: 'long_text' },
+ { label: 'Single select', value: 'single_select' },
+ { label: 'Multiple select', value: 'multi_select' },
+ { label: 'Yes / no', value: 'yes_no' },
+ { label: 'Date', value: 'date' },
+ ]);
+
+ rerender( );
+ expect(screen.getByRole('button', { name: 'Add question' })).toBeDisabled();
+ expect(screen.getByText('Questions: 50 / 50')).toBeInTheDocument();
+ });
+});
+
+const baseConfig = {
+ id: 'config-1',
+ propertyId: 'property-1',
+ isEnabled: true,
+ displayName: 'Harbour Hotel',
+ logoMediaId: null,
+ primaryColor: '#016491',
+ accentColor: '#f2641b',
+ sellableRoomTypeIds: ['room-type-1'],
+ sellableRatePlanIds: ['rate-plan-1'],
+ depositPolicy: { type: 'none' as const, refundable: true },
+ autoConfirm: false,
+ stripePublishableKey: 'pk_test_property',
+ bookingMode: 'request' as const,
+ paymentMethodCollection: 'optional' as const,
+ paymentMethodClientMode: 'stripe' as const,
+ formQuestions: [arrivalQuestion, breakfastQuestion],
+ updatedAt: '2026-08-25T00:00:00.000Z',
+};
+
+function mockQueries(config: Record = baseConfig) {
+ vi.mocked(api.get).mockImplementation((url: string) => {
+ if (url === '/v1/admin/booking-engine/config') {
+ return Promise.resolve({ data: { data: config } } as never);
+ }
+ return Promise.resolve({ data: { data: [] } } as never);
+ });
+}
+
+function renderSettings(propertyId = 'property-1', providedClient?: QueryClient) {
+ const queryClient = providedClient ?? new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0 },
+ mutations: { retry: false },
+ },
+ });
+ const settings = (nextPropertyId: string) => (
+
+
+
+
+
+ );
+ const view = render(settings(propertyId));
+ return Object.assign(view, {
+ queryClient,
+ switchProperty: (nextPropertyId: string) => view.rerender(settings(nextPropertyId)),
+ });
+}
+
+describe('BookingEngineSettings request configuration', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockQueries();
+ vi.mocked(api.patch).mockResolvedValue({ data: { data: baseConfig } } as never);
+ });
+
+ it('uses safe request defaults when older config responses omit the new fields', async () => {
+ const legacyConfig: Record = { ...baseConfig };
+ delete legacyConfig.bookingMode;
+ delete legacyConfig.paymentMethodCollection;
+ delete legacyConfig.formQuestions;
+ mockQueries(legacyConfig);
+ renderSettings();
+
+ expect(await screen.findByRole('combobox', { name: 'Booking mode' })).toHaveValue('instant');
+ expect(screen.getByRole('combobox', { name: 'Card collection' })).toHaveValue('disabled');
+ expect(screen.getByText('Questions: 0 / 50')).toBeInTheDocument();
+ });
+
+ it('warns and prevents saving required card collection without a Stripe publishable key', async () => {
+ mockQueries({
+ ...baseConfig,
+ stripePublishableKey: null,
+ paymentMethodCollection: 'required',
+ });
+ renderSettings();
+
+ const name = await screen.findByRole('textbox', { name: 'Display Name' });
+ await userEvent.clear(name);
+ await userEvent.type(name, 'Hotel without Stripe');
+
+ expect(screen.getByRole('alert')).toHaveTextContent('Add a Stripe publishable key before enabling card collection.');
+ expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
+ });
+
+ it('prevents saving optional Stripe card collection without a publishable key', async () => {
+ mockQueries({
+ ...baseConfig,
+ stripePublishableKey: null,
+ paymentMethodCollection: 'optional',
+ });
+ renderSettings();
+
+ const name = await screen.findByRole('textbox', { name: 'Display Name' });
+ await userEvent.clear(name);
+ await userEvent.type(name, 'Hotel without Stripe');
+
+ expect(screen.getByRole('alert')).toHaveTextContent(/Stripe publishable key/i);
+ expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
+ });
+
+ it('allows mock card collection without Stripe keys', async () => {
+ mockQueries({
+ ...baseConfig,
+ stripePublishableKey: null,
+ paymentMethodCollection: 'required',
+ paymentMethodClientMode: 'mock',
+ });
+ renderSettings();
+
+ const name = await screen.findByRole('textbox', { name: 'Display Name' });
+ await userEvent.clear(name);
+ await userEvent.type(name, 'Local mock hotel');
+
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled();
+ });
+
+ it.each(['optional', 'required'] as const)(
+ 'blocks %s card collection when the configured provider does not support saved cards',
+ async (paymentMethodCollection) => {
+ mockQueries({
+ ...baseConfig,
+ paymentMethodCollection,
+ paymentMethodClientMode: 'unsupported',
+ });
+ renderSettings();
+
+ const name = await screen.findByRole('textbox', { name: 'Display Name' });
+ await userEvent.clear(name);
+ await userEvent.type(name, 'Unsupported card provider');
+
+ expect(screen.getByRole('alert')).toHaveTextContent(/does not support saved cards/i);
+ expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
+ },
+ );
+
+ it('allows disabled card collection with an unsupported provider', async () => {
+ mockQueries({
+ ...baseConfig,
+ paymentMethodCollection: 'disabled',
+ paymentMethodClientMode: 'unsupported',
+ });
+ renderSettings();
+
+ const name = await screen.findByRole('textbox', { name: 'Display Name' });
+ await userEvent.clear(name);
+ await userEvent.type(name, 'Unsupported provider with cards off');
+
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Save changes' })).toBeEnabled();
+ });
+
+ it('resets dirty request changes and sends only changed fields with the version header', async () => {
+ renderSettings();
+ const mode = await screen.findByRole('combobox', { name: 'Booking mode' });
+ const cardPolicy = screen.getByRole('combobox', { name: 'Card collection' });
+
+ await userEvent.selectOptions(mode, 'instant');
+ await userEvent.selectOptions(cardPolicy, 'disabled');
+ expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
+ await userEvent.click(screen.getByRole('button', { name: 'Reset changes' }));
+ expect(mode).toHaveValue('request');
+ expect(cardPolicy).toHaveValue('optional');
+
+ await userEvent.selectOptions(cardPolicy, 'required');
+ await userEvent.click(screen.getByRole('switch', { name: 'Disable Arrival time' }));
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
+
+ await waitFor(() => expect(api.patch).toHaveBeenCalledOnce());
+ expect(vi.mocked(api.patch).mock.calls[0]).toEqual([
+ '/v1/admin/booking-engine/config',
+ {
+ paymentMethodCollection: 'required',
+ formQuestions: [
+ { ...arrivalQuestion, order: 0, isActive: false },
+ { ...breakfastQuestion, order: 1 },
+ ],
+ },
+ {
+ params: { propertyId: 'property-1' },
+ headers: { 'If-Match': '"2026-08-25T00:00:00.000Z"' },
+ },
+ ]);
+ });
+
+ it('uses the refreshed server version for the next save', async () => {
+ vi.mocked(api.patch)
+ .mockResolvedValueOnce({ data: { data: { ...baseConfig, bookingMode: 'instant', updatedAt: '2026-08-25T00:00:01.000Z' } } } as never)
+ .mockResolvedValueOnce({ data: { data: { ...baseConfig, bookingMode: 'instant', paymentMethodCollection: 'disabled', updatedAt: '2026-08-25T00:00:02.000Z' } } } as never);
+ renderSettings();
+
+ await userEvent.selectOptions(
+ await screen.findByRole('combobox', { name: 'Booking mode' }),
+ 'instant',
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
+ await waitFor(() => expect(api.patch).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument());
+
+ await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Card collection' }), 'disabled');
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
+ await waitFor(() => expect(api.patch).toHaveBeenCalledTimes(2));
+
+ expect(vi.mocked(api.patch).mock.calls[1]?.[1]).toEqual({
+ paymentMethodCollection: 'disabled',
+ });
+ expect(vi.mocked(api.patch).mock.calls[1]?.[2]).toEqual({
+ params: { propertyId: 'property-1' },
+ headers: { 'If-Match': '"2026-08-25T00:00:01.000Z"' },
+ });
+ });
+
+ it('keeps a stale draft intact and reloads latest settings only on explicit review', async () => {
+ const latest = {
+ ...baseConfig,
+ bookingMode: 'request' as const,
+ paymentMethodCollection: 'disabled' as const,
+ updatedAt: '2026-08-25T00:00:10.000Z',
+ };
+ let configReads = 0;
+ vi.mocked(api.get).mockImplementation((url: string) => {
+ if (url === '/v1/admin/booking-engine/config') {
+ configReads += 1;
+ return Promise.resolve({ data: { data: configReads === 1 ? baseConfig : latest } } as never);
+ }
+ return Promise.resolve({ data: { data: [] } } as never);
+ });
+ vi.mocked(api.patch).mockRejectedValue({ response: { status: 409 } });
+ renderSettings();
+
+ const mode = await screen.findByRole('combobox', { name: 'Booking mode' });
+ await userEvent.selectOptions(mode, 'instant');
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
+
+ expect(await screen.findByRole('alert', { name: 'Settings conflict' })).toHaveTextContent('changed since you opened this page');
+ expect(mode).toHaveValue('instant');
+ expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Reload latest settings' }));
+ await waitFor(() => expect(mode).toHaveValue('request'));
+ expect(screen.getByRole('combobox', { name: 'Card collection' })).toHaveValue('disabled');
+ expect(screen.queryByRole('alert', { name: 'Settings conflict' })).not.toBeInTheDocument();
+ expect(screen.queryByText('Could not save booking engine settings.')).not.toBeInTheDocument();
+ });
+
+ it('keeps the conflict draft when reloading the latest settings fails', async () => {
+ let configReads = 0;
+ vi.mocked(api.get).mockImplementation((url: string) => {
+ if (url === '/v1/admin/booking-engine/config') {
+ configReads += 1;
+ return configReads === 1
+ ? Promise.resolve({ data: { data: baseConfig } } as never)
+ : Promise.reject(new Error('offline'));
+ }
+ return Promise.resolve({ data: { data: [] } } as never);
+ });
+ vi.mocked(api.patch).mockRejectedValue({ response: { status: 409 } });
+ renderSettings();
+
+ const mode = await screen.findByRole('combobox', { name: 'Booking mode' });
+ await userEvent.selectOptions(mode, 'instant');
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
+ await userEvent.click(await screen.findByRole('button', { name: 'Reload latest settings' }));
+
+ await waitFor(() => expect(configReads).toBe(2));
+ expect(mode).toHaveValue('instant');
+ expect(screen.getByRole('alert', { name: 'Settings conflict' })).toBeInTheDocument();
+ });
+
+ it('omits untouched historical definitions when another setting is saved', async () => {
+ renderSettings();
+ await screen.findByRole('combobox', { name: 'Booking mode' });
+ await userEvent.click(screen.getByRole('switch', { name: 'Disable Arrival time' }));
+ await userEvent.click(screen.getByRole('switch', { name: 'Enable Arrival time' }));
+ await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Booking mode' }), 'instant');
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
+
+ await waitFor(() => expect(api.patch).toHaveBeenCalledOnce());
+ expect(vi.mocked(api.patch).mock.calls[0]?.[1]).toEqual({
+ bookingMode: 'instant',
+ });
+ });
+
+ it('treats an open question draft as unsaved and reset explicitly discards it', async () => {
+ renderSettings();
+ await screen.findByRole('combobox', { name: 'Booking mode' });
+ await userEvent.click(screen.getByRole('button', { name: 'Add question' }));
+ await userEvent.type(screen.getByRole('textbox', { name: 'Question label' }), 'Pending draft');
+
+ expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
+ const reset = screen.getByRole('button', { name: 'Reset changes' });
+ expect(reset).toBeEnabled();
+ await userEvent.click(reset);
+
+ expect(screen.queryByRole('heading', { name: 'Add a question' })).not.toBeInTheDocument();
+ expect(screen.queryByDisplayValue('Pending draft')).not.toBeInTheDocument();
+ expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
+ });
+
+ it('keeps cached settings usable when a background refresh fails', async () => {
+ const { queryClient } = renderSettings();
+ const mode = await screen.findByRole('combobox', { name: 'Booking mode' });
+ vi.mocked(api.get).mockImplementation((url: string) => {
+ if (url === '/v1/admin/booking-engine/config') return Promise.reject(new Error('offline'));
+ return Promise.resolve({ data: { data: [] } } as never);
+ });
+
+ await queryClient.invalidateQueries({ queryKey: ['booking-engine', 'config', 'property-1'] });
+
+ expect(await screen.findByText('Latest settings could not be checked. Your loaded settings are still available.')).toBeInTheDocument();
+ expect(mode).toBeInTheDocument();
+ expect(mode).toHaveValue('request');
+ });
+
+ it('preserves unknown definitions on unrelated saves and blocks question publishing when one is active', async () => {
+ const futureQuestion = {
+ id: THIRD_ID,
+ label: 'Future score',
+ type: 'rating_scale',
+ order: 0,
+ isActive: true,
+ isRequired: false,
+ futureConfig: { maximum: 10 },
+ };
+ mockQueries({ ...baseConfig, formQuestions: [futureQuestion] });
+ vi.mocked(api.patch).mockResolvedValue({
+ data: { data: { ...baseConfig, bookingMode: 'instant', formQuestions: [futureQuestion], updatedAt: '2026-08-25T00:00:01.000Z' } },
+ } as never);
+ renderSettings();
+
+ expect(await screen.findByText('An unsupported question is active')).toBeInTheDocument();
+ await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Booking mode' }), 'instant');
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
+ await waitFor(() => expect(api.patch).toHaveBeenCalledOnce());
+ expect(vi.mocked(api.patch).mock.calls[0]?.[1]).toEqual({
+ bookingMode: 'instant',
+ });
+
+ vi.mocked(api.patch).mockClear();
+ await userEvent.click(screen.getByRole('button', { name: 'Add question' }));
+ await userEvent.type(screen.getByRole('textbox', { name: 'Question label' }), 'Known question');
+ await userEvent.click(screen.getByRole('button', { name: 'Save question' }));
+
+ expect(screen.getByRole('button', { name: 'Save changes' })).toBeDisabled();
+ expect(screen.getByText('Use a newer dashboard before publishing changes to this guest form.')).toBeInTheDocument();
+ });
+
+ it('shows loading, load failure with retry, and save failure states', async () => {
+ let rejectConfig: ((reason?: unknown) => void) | undefined;
+ vi.mocked(api.get).mockImplementation((url: string) => {
+ if (url === '/v1/admin/booking-engine/config') {
+ return new Promise((_resolve, reject) => { rejectConfig = reject; }) as never;
+ }
+ return Promise.resolve({ data: { data: [] } } as never);
+ });
+ const firstRender = renderSettings();
+ expect(screen.getByRole('status')).toHaveTextContent('Loading booking engine settings');
+ await act(async () => { rejectConfig?.(new Error('offline')); });
+ expect(await screen.findByRole('alert')).toHaveTextContent('Could not load booking engine settings.');
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
+ firstRender.unmount();
+
+ mockQueries();
+ vi.mocked(api.patch).mockRejectedValue(new Error('save failed'));
+ renderSettings();
+ await userEvent.selectOptions(
+ await screen.findByRole('combobox', { name: 'Booking mode' }),
+ 'instant',
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Save changes' }));
+
+ expect(await screen.findByText('Could not save booking engine settings.')).toBeInTheDocument();
+ await userEvent.click(screen.getByRole('button', { name: 'Reset changes' }));
+ expect(screen.queryByText('Could not save booking engine settings.')).not.toBeInTheDocument();
+ });
+
+ it('discards an open Add draft when switching to an uncached property', async () => {
+ const secondConfig = {
+ ...baseConfig,
+ id: 'config-2',
+ propertyId: 'property-2',
+ displayName: 'Second hotel',
+ formQuestions: [],
+ updatedAt: '2026-08-25T01:00:00.000Z',
+ };
+ let resolveSecond: ((value: unknown) => void) | undefined;
+ vi.mocked(api.get).mockImplementation((url: string, options?: { params?: { propertyId?: string } }) => {
+ if (url === '/v1/admin/booking-engine/config') {
+ if (options?.params?.propertyId === 'property-2') {
+ return new Promise((resolve) => { resolveSecond = resolve; }) as never;
+ }
+ return Promise.resolve({ data: { data: baseConfig } } as never);
+ }
+ return Promise.resolve({ data: { data: [] } } as never);
+ });
+ const view = renderSettings();
+ await userEvent.click(await screen.findByRole('button', { name: 'Add question' }));
+ await userEvent.type(screen.getByRole('textbox', { name: 'Question label' }), 'Phantom draft');
+
+ view.switchProperty('property-2');
+ expect(screen.getByRole('status')).toHaveTextContent('Loading booking engine settings');
+ await act(async () => resolveSecond?.({ data: { data: secondConfig } }));
+
+ expect(await screen.findByRole('textbox', { name: 'Display Name' })).toHaveValue('Second hotel');
+ expect(screen.queryByDisplayValue('Phantom draft')).not.toBeInTheDocument();
+ expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
+ });
+
+ it('discards an open Edit draft and mutation state when switching to a cached property', async () => {
+ const secondConfig = {
+ ...baseConfig,
+ id: 'config-2',
+ propertyId: 'property-2',
+ displayName: 'Cached hotel',
+ formQuestions: [],
+ updatedAt: '2026-08-25T02:00:00.000Z',
+ };
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
+ });
+ queryClient.setQueryData(['booking-engine', 'config', 'property-2'], { data: secondConfig });
+ vi.mocked(api.get).mockImplementation((url: string, options?: { params?: { propertyId?: string } }) => {
+ if (url === '/v1/admin/booking-engine/config') {
+ return Promise.resolve({
+ data: { data: options?.params?.propertyId === 'property-2' ? secondConfig : baseConfig },
+ } as never);
+ }
+ return Promise.resolve({ data: { data: [] } } as never);
+ });
+ const view = renderSettings('property-1', queryClient);
+ await userEvent.click(await screen.findByRole('button', { name: 'Edit Arrival time' }));
+ await userEvent.clear(screen.getByRole('textbox', { name: 'Question label' }));
+ await userEvent.type(screen.getByRole('textbox', { name: 'Question label' }), 'Phantom edit');
+
+ view.switchProperty('property-2');
+
+ expect(await screen.findByRole('textbox', { name: 'Display Name' })).toHaveValue('Cached hotel');
+ expect(screen.queryByDisplayValue('Phantom edit')).not.toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'Edit question' })).not.toBeInTheDocument();
+ expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
+ });
+
+ it('allows only one save mutation at a time', async () => {
+ let resolveSave: ((value: unknown) => void) | undefined;
+ vi.mocked(api.patch).mockImplementation(() => new Promise((resolve) => { resolveSave = resolve; }) as never);
+ renderSettings();
+ await userEvent.selectOptions(
+ await screen.findByRole('combobox', { name: 'Booking mode' }),
+ 'instant',
+ );
+ const save = screen.getByRole('button', { name: 'Save changes' });
+ await userEvent.click(save);
+
+ expect(save).toBeDisabled();
+ await userEvent.click(save);
+ expect(api.patch).toHaveBeenCalledOnce();
+ await act(async () => {
+ resolveSave?.({ data: { data: { ...baseConfig, bookingMode: 'instant' } } });
+ });
+ });
+});
+
+describe('booking request settings translations', () => {
+ it('defines every visible booking-engine string in every supported locale', () => {
+ const locales = { en, de, es, fr, hr, it: itMessages, 'pt-BR': ptBR, 'sr-Latn': srLatn };
+ const leafPaths = (value: unknown, prefix = ''): string[] => Object.entries(value as Record)
+ .flatMap(([key, child]) => child && typeof child === 'object'
+ ? leafPaths(child, prefix ? `${prefix}.${key}` : key)
+ : [prefix ? `${prefix}.${key}` : key]);
+ const sourcePaths = leafPaths(en.bookingEngine).sort();
+ for (const [locale, messages] of Object.entries(locales)) {
+ const bookingEngine = (messages as { bookingEngine?: unknown }).bookingEngine;
+ expect(bookingEngine, `${locale} bookingEngine`).toBeTypeOf('object');
+ expect(leafPaths(bookingEngine).sort(), `${locale} bookingEngine keys`).toEqual(sourcePaths);
+ expect(JSON.stringify(bookingEngine), `${locale} bookingEngine`).not.toContain('""');
+ }
+ });
+});
diff --git a/apps/dashboard/src/components/admin/BookingQuestionBuilder.tsx b/apps/dashboard/src/components/admin/BookingQuestionBuilder.tsx
new file mode 100644
index 00000000..34bd611d
--- /dev/null
+++ b/apps/dashboard/src/components/admin/BookingQuestionBuilder.tsx
@@ -0,0 +1,585 @@
+import {
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type Dispatch,
+ type RefObject,
+ type SetStateAction,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ ArrowDown,
+ ArrowUp,
+ Pencil,
+ Plus,
+ Trash2,
+ X,
+} from 'lucide-react';
+import {
+ MAX_OPTIONS,
+ MAX_QUESTIONS,
+ QUESTION_TYPES,
+ SELECT_TYPES,
+ hasDuplicateQuestionIds,
+ isSupportedQuestion,
+ questionOptionsAreValid,
+ type BookingFormQuestion,
+ type BookingFormQuestionDefinition,
+ type BookingFormQuestionType,
+} from './booking-request-config';
+
+interface BookingQuestionBuilderProps {
+ questions: BookingFormQuestionDefinition[];
+ onChange: (questions: BookingFormQuestionDefinition[]) => void;
+ disabled?: boolean;
+ idFactory?: () => string;
+ onEditorOpenChange?: (isOpen: boolean) => void;
+}
+
+function defaultIdFactory() {
+ return crypto.randomUUID();
+}
+
+function createUniqueId(questions: BookingFormQuestionDefinition[], idFactory: () => string) {
+ const ids = new Set(questions.map((question) => question.id));
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ const id = idFactory();
+ if (!ids.has(id)) return id;
+ }
+ throw new Error('Could not create a unique question id');
+}
+
+function sortedQuestions(questions: BookingFormQuestionDefinition[]) {
+ return questions
+ .map((question, index) => ({ question, index }))
+ .sort((left, right) => left.question.order - right.question.order || left.index - right.index)
+ .map(({ question }) => question);
+}
+
+function normalizedQuestions(questions: BookingFormQuestionDefinition[]) {
+ return sortedQuestions(questions).map((question, order) => ({ ...question, order }));
+}
+
+export default function BookingQuestionBuilder({
+ questions,
+ onChange,
+ disabled = false,
+ idFactory = defaultIdFactory,
+ onEditorOpenChange,
+}: BookingQuestionBuilderProps) {
+ const { t } = useTranslation();
+ const orderedQuestions = useMemo(() => sortedQuestions(questions), [questions]);
+ const [draft, setDraft] = useState(null);
+ const [editingId, setEditingId] = useState(null);
+ const [builderError, setBuilderError] = useState(null);
+ const [optionKeys, setOptionKeys] = useState([]);
+ const nextOptionKey = useRef(0);
+ const addButtonRef = useRef(null);
+ const labelInputRef = useRef(null);
+ const editButtonRefs = useRef(new Map());
+ const returnFocusRef = useRef<'add' | string>('add');
+ const duplicateIds = hasDuplicateQuestionIds(questions);
+
+ useEffect(() => {
+ if (draft) labelInputRef.current?.focus();
+ }, [draft?.id]);
+
+ const restoreFocus = (target = returnFocusRef.current) => {
+ window.setTimeout(() => {
+ if (target === 'add') addButtonRef.current?.focus();
+ else editButtonRefs.current.get(target)?.focus();
+ }, 0);
+ };
+
+ const openNewQuestion = () => {
+ if (disabled || questions.length >= MAX_QUESTIONS || duplicateIds) return;
+ try {
+ const nextOrder = questions.length === 0
+ ? 0
+ : Math.max(...questions.map((question) => question.order)) + 1;
+ setDraft({
+ id: createUniqueId(questions, idFactory),
+ label: '',
+ type: 'short_text',
+ order: nextOrder,
+ isActive: true,
+ isRequired: false,
+ });
+ setEditingId(null);
+ returnFocusRef.current = 'add';
+ setOptionKeys([]);
+ setBuilderError(null);
+ onEditorOpenChange?.(true);
+ } catch {
+ setBuilderError(t('bookingEngine.questions.idError'));
+ }
+ };
+
+ const openEditQuestion = (question: BookingFormQuestion) => {
+ if (disabled) return;
+ setDraft({
+ id: question.id,
+ label: question.label,
+ type: question.type,
+ ...(question.options ? { options: [...question.options] } : {}),
+ order: question.order,
+ isActive: question.isActive,
+ isRequired: question.isRequired,
+ });
+ setEditingId(question.id);
+ returnFocusRef.current = question.id;
+ setOptionKeys((question.options ?? []).map((_, index) => `${question.id}-existing-${index}`));
+ setBuilderError(null);
+ onEditorOpenChange?.(true);
+ };
+
+ const closeEditor = (restore = true) => {
+ const target = returnFocusRef.current;
+ setDraft(null);
+ setEditingId(null);
+ setOptionKeys([]);
+ onEditorOpenChange?.(false);
+ if (restore) restoreFocus(target);
+ };
+
+ const selectType = (type: BookingFormQuestionType) => {
+ if (SELECT_TYPES.has(type) && draft && !SELECT_TYPES.has(draft.type)) {
+ setOptionKeys([`${draft.id}-option-${nextOptionKey.current++}`]);
+ } else if (!SELECT_TYPES.has(type)) {
+ setOptionKeys([]);
+ }
+ setDraft((current) => {
+ if (!current) return current;
+ if (SELECT_TYPES.has(type)) {
+ return {
+ ...current,
+ type,
+ options: SELECT_TYPES.has(current.type) ? [...(current.options ?? [''])] : [''],
+ };
+ }
+ const withoutOptions: BookingFormQuestion = { ...current, type };
+ delete withoutOptions.options;
+ return withoutOptions;
+ });
+ };
+
+ const saveDraft = () => {
+ if (!draft) return;
+ const label = draft.label.trim();
+ const options = SELECT_TYPES.has(draft.type)
+ ? draft.options?.map((option) => option.trim())
+ : undefined;
+ if (!label || label.length > 200) return;
+ if (SELECT_TYPES.has(draft.type) && !questionOptionsAreValid(options)) return;
+
+ const saved: BookingFormQuestion = {
+ id: draft.id,
+ label,
+ type: draft.type,
+ ...(options ? { options } : {}),
+ order: draft.order,
+ isActive: draft.isActive,
+ isRequired: draft.isRequired,
+ };
+ onChange(normalizedQuestions(editingId
+ ? questions.map((question) => question.id === editingId ? saved : question)
+ : [...questions, saved]));
+ if (!editingId && questions.length + 1 >= MAX_QUESTIONS) {
+ returnFocusRef.current = saved.id;
+ }
+ closeEditor();
+ };
+
+ const moveQuestion = (id: string, direction: -1 | 1) => {
+ if (disabled || draft) return;
+ const currentIndex = orderedQuestions.findIndex((question) => question.id === id);
+ const targetIndex = currentIndex + direction;
+ if (currentIndex < 0 || targetIndex < 0 || targetIndex >= orderedQuestions.length) return;
+ const moved = [...orderedQuestions];
+ const [question] = moved.splice(currentIndex, 1);
+ moved.splice(targetIndex, 0, question!);
+ const orderById = new Map(moved.map((item, order) => [item.id, order]));
+ onChange(questions.map((item) => ({ ...item, order: orderById.get(item.id)! })));
+ };
+
+ const toggleQuestion = (question: BookingFormQuestionDefinition) => {
+ if (disabled || draft || !isSupportedQuestion(question)) return;
+ onChange(questions.map((item) => item.id === question.id
+ ? { ...item, isActive: !item.isActive }
+ : item));
+ };
+
+ const removeQuestion = (id: string) => {
+ if (disabled || draft) return;
+ onChange(normalizedQuestions(questions.filter((question) => question.id !== id)));
+ if (editingId === id) closeEditor();
+ else restoreFocus('add');
+ };
+
+ const updateOption = (index: number, value: string) => {
+ setDraft((current) => {
+ if (!current) return current;
+ const options = [...(current.options ?? [])];
+ options[index] = value;
+ return { ...current, options };
+ });
+ };
+
+ const addOption = () => {
+ if (draft && (draft.options?.length ?? 0) < MAX_OPTIONS) {
+ const key = `${draft.id}-option-${nextOptionKey.current++}`;
+ setOptionKeys((current) => [...current, key]);
+ }
+ setDraft((current) => current && (current.options?.length ?? 0) < MAX_OPTIONS
+ ? { ...current, options: [...(current.options ?? []), ''] }
+ : current);
+ };
+
+ const moveOption = (index: number, direction: -1 | 1) => {
+ setOptionKeys((current) => {
+ const targetIndex = index + direction;
+ if (targetIndex < 0 || targetIndex >= current.length) return current;
+ const moved = [...current];
+ [moved[index], moved[targetIndex]] = [moved[targetIndex]!, moved[index]!];
+ return moved;
+ });
+ setDraft((current) => {
+ if (!current?.options) return current;
+ const targetIndex = index + direction;
+ if (targetIndex < 0 || targetIndex >= current.options.length) return current;
+ const options = [...current.options];
+ [options[index], options[targetIndex]] = [options[targetIndex]!, options[index]!];
+ return { ...current, options };
+ });
+ };
+
+ const removeOption = (index: number) => {
+ setOptionKeys((current) => current.filter((_, optionIndex) => optionIndex !== index));
+ setDraft((current) => current?.options
+ ? { ...current, options: current.options.filter((_, optionIndex) => optionIndex !== index) }
+ : current);
+ };
+
+ return (
+
+
+
+
+
+ {t('bookingEngine.questions.description')}
+
+
+ {t('bookingEngine.questions.count', { count: questions.length, max: MAX_QUESTIONS })}
+
+
+
= MAX_QUESTIONS || duplicateIds}
+ className="inline-flex items-center justify-center gap-2 bg-telivity-deep-blue text-white rounded-lg px-3 py-2 text-sm font-semibold disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue focus-visible:ring-offset-2 shrink-0 motion-reduce:transition-none"
+ >
+
+ {t('bookingEngine.questions.add')}
+
+
+
+ {(duplicateIds || builderError) && (
+
+ {builderError ?? t('bookingEngine.questions.duplicateIds')}
+
+ )}
+
+ {questions.some((question) => !isSupportedQuestion(question) && question.isActive) && (
+
+
{t('bookingEngine.questions.unsupportedActiveTitle')}
+
{t('bookingEngine.questions.unsupportedActiveDescription')}
+
+ )}
+
+ {orderedQuestions.length === 0 && !draft ? (
+
+
{t('bookingEngine.questions.emptyTitle')}
+
{t('bookingEngine.questions.emptyDescription')}
+
+ ) : (
+ isSupportedQuestion(question) && openEditQuestion(question)}
+ onRemove={removeQuestion}
+ setEditButtonRef={(id, element) => {
+ if (element) editButtonRefs.current.set(id, element);
+ else editButtonRefs.current.delete(id);
+ }}
+ />
+ )}
+
+ {draft && }
+
+ );
+}
+
+function QuestionList({
+ questions,
+ disabled,
+ onMove,
+ onToggle,
+ onEdit,
+ onRemove,
+ setEditButtonRef,
+}: {
+ questions: BookingFormQuestionDefinition[];
+ disabled: boolean;
+ onMove: (id: string, direction: -1 | 1) => void;
+ onToggle: (question: BookingFormQuestionDefinition) => void;
+ onEdit: (question: BookingFormQuestionDefinition) => void;
+ onRemove: (id: string) => void;
+ setEditButtonRef: (id: string, element: HTMLButtonElement | null) => void;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+ {questions.map((question, index) => {
+ const supported = isSupportedQuestion(question);
+ return (
+
+
+
+ {String(index + 1).padStart(2, '0')}
+
+
+
+ {question.label}
+
+ {supported
+ ? t(`bookingEngine.questions.types.${question.type}`)
+ : t('bookingEngine.questions.unsupportedType')}
+
+
+ {question.isRequired ? t('bookingEngine.questions.required') : t('bookingEngine.questions.optional')}
+
+
+ {question.isActive ? t('bookingEngine.questions.active') : t('bookingEngine.questions.inactive')}
+
+
+ {Array.isArray(question.options) && (
+
+ {question.options.join(' · ')}
+
+ )}
+
+
+
onMove(question.id, -1)}
+ disabled={disabled || index === 0}
+ >
+
onMove(question.id, 1)}
+ disabled={disabled || index === questions.length - 1}
+ >
+
onToggle(question)}
+ disabled={disabled || !supported}
+ className={`relative w-9 h-5 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue focus-visible:ring-offset-2 disabled:opacity-50 motion-reduce:transition-none ${question.isActive ? 'bg-telivity-deep-blue' : 'bg-telivity-slate'}`}
+ >
+
+
+
onEdit(question)}
+ disabled={disabled || !supported}
+ buttonRef={(element) => setEditButtonRef(question.id, element)}
+ >
+
onRemove(question.id)}
+ disabled={disabled || !supported}
+ danger
+ >
+
+
+
+ );
+ })}
+
+ );
+}
+
+function QuestionEditor({
+ draft,
+ setDraft,
+ editing,
+ optionKeys,
+ labelInputRef,
+ onTypeChange,
+ onUpdateOption,
+ onAddOption,
+ onMoveOption,
+ onRemoveOption,
+ onSave,
+ onCancel,
+}: {
+ draft: BookingFormQuestion;
+ setDraft: Dispatch>;
+ editing: boolean;
+ optionKeys: string[];
+ labelInputRef: RefObject;
+ onTypeChange: (type: BookingFormQuestionType) => void;
+ onUpdateOption: (index: number, value: string) => void;
+ onAddOption: () => void;
+ onMoveOption: (index: number, direction: -1 | 1) => void;
+ onRemoveOption: (index: number) => void;
+ onSave: () => void;
+ onCancel: () => void;
+}) {
+ const { t } = useTranslation();
+ const optionValues = draft.options ?? [];
+ const hasBlankOption = optionValues.some((option) => option.trim().length === 0);
+ const normalizedOptions = optionValues.map((option) => option.trim().toLocaleLowerCase());
+ const hasDuplicateOption = !hasBlankOption
+ && new Set(normalizedOptions).size !== normalizedOptions.length;
+ const optionErrorId = hasBlankOption
+ ? 'booking-question-options-blank'
+ : hasDuplicateOption
+ ? 'booking-question-options-duplicate'
+ : undefined;
+ const labelInvalid = draft.label.trim().length === 0;
+ const draftValid = !labelInvalid
+ && draft.label.trim().length <= 200
+ && (!SELECT_TYPES.has(draft.type) || questionOptionsAreValid(draft.options));
+
+ return (
+
+
+
+ {editing ? t('bookingEngine.questions.editTitle') : t('bookingEngine.questions.addTitle')}
+
+
+
+
+
+
+
+
+
{t('bookingEngine.questions.label')}
+
setDraft((current) => current ? { ...current, label: event.target.value } : current)}
+ className="w-full border border-telivity-slate rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-deep-blue focus-visible:ring-2 focus-visible:ring-telivity-deep-blue"
+ />
+ {labelInvalid &&
{t('bookingEngine.questions.labelRequired')}
}
+
+
+ {t('bookingEngine.questions.type')}
+ onTypeChange(event.target.value as BookingFormQuestionType)} className="w-full border border-telivity-slate rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-deep-blue focus-visible:ring-2 focus-visible:ring-telivity-deep-blue">
+ {QUESTION_TYPES.map((type) => {t(`bookingEngine.questions.types.${type}`)} )}
+
+
+
+
+
+
+ setDraft((current) => current ? { ...current, isRequired: event.target.checked } : current)} className="accent-telivity-deep-blue focus:ring-telivity-deep-blue" />
+ {t('bookingEngine.questions.requiredQuestion')}
+
+
+ setDraft((current) => current ? { ...current, isActive: event.target.checked } : current)} className="accent-telivity-deep-blue focus:ring-telivity-deep-blue" />
+ {t('bookingEngine.questions.activeQuestion')}
+
+
+
+ {SELECT_TYPES.has(draft.type) && (
+
+ {t('bookingEngine.questions.options')}
+
+
= MAX_OPTIONS} className="inline-flex items-center gap-1 text-sm font-semibold text-telivity-deep-blue disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue rounded">
+ {t('bookingEngine.questions.addOption')}
+
+
+
+ {optionValues.map((option, index) => (
+
+
onUpdateOption(index, event.target.value)} className="min-w-0 flex-1 border border-telivity-slate rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-deep-blue focus-visible:ring-2 focus-visible:ring-telivity-deep-blue" />
+
onMoveOption(index, -1)} disabled={index === 0}>
+
onMoveOption(index, 1)} disabled={index === optionValues.length - 1}>
+
onRemoveOption(index)} danger>
+
+ ))}
+
+ {optionValues.length === 0 && {t('bookingEngine.questions.optionRequired')}
}
+ {hasBlankOption && {t('bookingEngine.questions.optionBlank')}
}
+ {hasDuplicateOption && {t('bookingEngine.questions.optionDuplicate')}
}
+
+ )}
+
+
+ {t('bookingEngine.questions.save')}
+ {t('bookingEngine.questions.cancel')}
+
+
+ );
+}
+
+function IconButton({
+ label,
+ onClick,
+ disabled = false,
+ danger = false,
+ buttonRef,
+ children,
+}: {
+ label: string;
+ onClick: () => void;
+ disabled?: boolean;
+ danger?: boolean;
+ buttonRef?: (element: HTMLButtonElement | null) => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/dashboard/src/components/admin/booking-request-config.ts b/apps/dashboard/src/components/admin/booking-request-config.ts
new file mode 100644
index 00000000..29dccb47
--- /dev/null
+++ b/apps/dashboard/src/components/admin/booking-request-config.ts
@@ -0,0 +1,79 @@
+export type BookingFormQuestionType =
+ | 'short_text'
+ | 'long_text'
+ | 'single_select'
+ | 'multi_select'
+ | 'yes_no'
+ | 'date';
+
+export type BookingFormQuestion = {
+ id: string;
+ label: string;
+ type: BookingFormQuestionType;
+ options?: string[];
+ order: number;
+ isActive: boolean;
+ isRequired: boolean;
+};
+
+export type UnsupportedBookingFormQuestion = {
+ id: string;
+ label: string;
+ type: string;
+ order: number;
+ isActive: boolean;
+ isRequired: boolean;
+ [key: string]: unknown;
+};
+
+export type BookingFormQuestionDefinition = BookingFormQuestion | UnsupportedBookingFormQuestion;
+
+export const QUESTION_TYPES: readonly BookingFormQuestionType[] = [
+ 'short_text',
+ 'long_text',
+ 'single_select',
+ 'multi_select',
+ 'yes_no',
+ 'date',
+];
+
+export const SELECT_TYPES = new Set(['single_select', 'multi_select']);
+export const MAX_QUESTIONS = 50;
+export const MAX_OPTIONS = 50;
+
+export function isSupportedQuestion(
+ question: BookingFormQuestionDefinition,
+): question is BookingFormQuestion {
+ return QUESTION_TYPES.includes(question.type as BookingFormQuestionType);
+}
+
+export function hasActiveUnsupportedQuestions(questions: BookingFormQuestionDefinition[]) {
+ return questions.some((question) => !isSupportedQuestion(question) && question.isActive);
+}
+
+export function questionOptionsAreValid(options: string[] | undefined) {
+ if (!options || options.length === 0 || options.length > MAX_OPTIONS) return false;
+ if (options.some((option) => option.trim().length === 0 || option.length > 200)) return false;
+ const normalized = options.map((option) => option.trim().toLocaleLowerCase());
+ return new Set(normalized).size === normalized.length;
+}
+
+export function hasDuplicateQuestionIds(questions: BookingFormQuestionDefinition[]) {
+ return new Set(questions.map((question) => question.id)).size !== questions.length;
+}
+
+export function bookingQuestionsAreValid(questions: BookingFormQuestionDefinition[]) {
+ return questions.length <= MAX_QUESTIONS
+ && !hasDuplicateQuestionIds(questions)
+ && questions.every((question) => question.id.trim().length > 0
+ && question.label.trim().length > 0
+ && question.label.length <= 200
+ && Number.isInteger(question.order)
+ && question.order >= 0
+ && typeof question.isActive === 'boolean'
+ && typeof question.isRequired === 'boolean'
+ && (!isSupportedQuestion(question)
+ || (SELECT_TYPES.has(question.type)
+ ? questionOptionsAreValid(question.options)
+ : question.options === undefined)));
+}
diff --git a/apps/dashboard/src/components/booking-requests/AcceptRequestModal.tsx b/apps/dashboard/src/components/booking-requests/AcceptRequestModal.tsx
new file mode 100644
index 00000000..b94dcc0d
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/AcceptRequestModal.tsx
@@ -0,0 +1,219 @@
+import { useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { api } from '../../lib/api';
+import { formatMoney } from '../../lib/money';
+import Modal from '../ui/Modal';
+import { bookingRequestKeys } from './queryKeys';
+import { validateMoneyInput } from './moneyInput';
+import {
+ apiErrorMessage,
+ quoteTotal,
+ type BookingRequestAcceptancePreview,
+ type BookingRequestDetail,
+ type BookingRequestPriceSource,
+} from './types';
+
+interface AcceptRequestModalProps {
+ request: BookingRequestDetail;
+ propertyId: string;
+ onClose: () => void;
+}
+
+export default function AcceptRequestModal({
+ request,
+ propertyId,
+ onClose,
+}: AcceptRequestModalProps) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const [priceSource, setPriceSource] = useState>('submitted');
+ const [customTotal, setCustomTotal] = useState('');
+ const [customReason, setCustomReason] = useState('');
+ const submittedTotal = quoteTotal(request.submittedQuoteSnapshot);
+ const previewQuery = useQuery({
+ queryKey: bookingRequestKeys.acceptancePreview(propertyId, request.id),
+ queryFn: () => api.get(
+ `/v1/booking-requests/${request.id}/acceptance-preview`,
+ { params: { propertyId } },
+ ).then((response) => response.data?.data ?? response.data),
+ });
+ const candidatePreview = previewQuery.data as BookingRequestAcceptancePreview | undefined;
+ const preview = candidatePreview?.requestId === request.id
+ ? candidatePreview
+ : undefined;
+ const currentTotal = preview?.currentTotal;
+ const customValidation = validateMoneyInput(customTotal, request.currencyCode);
+ const customAmountError = priceSource === 'custom'
+ && customTotal !== ''
+ && customValidation.error != null;
+ const customReady = priceSource !== 'custom'
+ || (customValidation.canonical != null && customReason.trim().length > 0);
+
+ const mutation = useMutation({
+ mutationFn: () => api.post(
+ `/v1/booking-requests/${request.id}/accept`,
+ {
+ priceSource,
+ previewToken: preview?.previewToken,
+ ...(priceSource === 'custom' ? {
+ customTotal: customValidation.canonical!,
+ customReason: customReason.trim(),
+ } : {}),
+ },
+ { params: { propertyId } },
+ ),
+ onError: async (error) => {
+ const status = (error as { response?: { status?: number } })?.response?.status;
+ if (status === 409) await previewQuery.refetch();
+ },
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.root(propertyId) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.payments(propertyId, request.id) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.installments(propertyId, request.id) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.messages(propertyId, request.id) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.audit(propertyId, request.id) }),
+ queryClient.invalidateQueries({ queryKey: ['reservations', propertyId] }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.genericFoliosRoot(propertyId) }),
+ queryClient.invalidateQueries({ queryKey: ['payments', propertyId] }),
+ ]);
+ onClose();
+ },
+ });
+
+ const options: Array<{
+ value: Exclude;
+ label: string;
+ amount: string;
+ description: string;
+ }> = [
+ {
+ value: 'submitted',
+ label: t('bookingRequests.accept.submitted'),
+ amount: formatMoney(submittedTotal, request.currencyCode),
+ description: t('bookingRequests.accept.submittedDescription'),
+ },
+ {
+ value: 'current',
+ label: t('bookingRequests.accept.current'),
+ amount: currentTotal
+ ? formatMoney(currentTotal, request.currencyCode)
+ : t('bookingRequests.accept.recheckedOnAccept'),
+ description: t('bookingRequests.accept.currentDescription'),
+ },
+ {
+ value: 'custom',
+ label: t('bookingRequests.accept.custom'),
+ amount: t('bookingRequests.accept.enterAmount'),
+ description: t('bookingRequests.accept.customDescription'),
+ },
+ ];
+
+ return (
+
+
+ {t('bookingRequests.accept.independence')}
+
+
+ {previewQuery.isLoading ? (
+
+ {t('bookingRequests.common.loading')}
+
+ ) : previewQuery.isError || !preview ? (
+
+ {t('bookingRequests.accept.error')}
+ previewQuery.refetch()} className="font-semibold text-telivity-deep-blue underline underline-offset-2">
+ {t('bookingRequests.common.retry')}
+
+
+ ) : null}
+
+
+ {t('bookingRequests.accept.priceChoice')}
+ {options.map((option) => (
+
+ setPriceSource(option.value)}
+ className="mt-1 text-telivity-deep-blue focus:ring-telivity-deep-blue"
+ />
+
+
+ {option.label}
+ {option.amount}
+
+ {option.description}
+
+
+ ))}
+
+
+ {priceSource === 'custom' ? (
+
+
+ {t('bookingRequests.accept.customTotal')}
+ setCustomTotal(event.target.value)}
+ className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-telivity-navy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue"
+ />
+ {customAmountError ? (
+
+ {t(`bookingRequests.validation.${customValidation.error}`)}
+
+ ) : null}
+
+
+ {t('bookingRequests.accept.customReason')}
+ setCustomReason(event.target.value)}
+ className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-telivity-navy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue"
+ />
+
+
+ ) : null}
+
+ {mutation.isError ? (
+
+ {apiErrorMessage(mutation.error, t('bookingRequests.accept.error'))}
+
+ ) : null}
+
+
+
+ {t('bookingRequests.common.cancel')}
+
+ mutation.mutate()}
+ disabled={!preview || !customReady || mutation.isPending}
+ className="rounded-lg bg-telivity-deep-blue px-4 py-2 text-sm font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ {mutation.isPending ? t('bookingRequests.accept.accepting') : t('bookingRequests.actions.accept')}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/booking-requests/DenyRequestModal.tsx b/apps/dashboard/src/components/booking-requests/DenyRequestModal.tsx
new file mode 100644
index 00000000..2dfe9bed
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/DenyRequestModal.tsx
@@ -0,0 +1,104 @@
+import { useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { api } from '../../lib/api';
+import { formatMoney } from '../../lib/money';
+import Modal from '../ui/Modal';
+import { bookingRequestKeys } from './queryKeys';
+import { apiErrorMessage } from './types';
+
+interface DenyRequestModalProps {
+ requestId: string;
+ propertyId: string;
+ currencyCode: string;
+ unresolvedAmount: number;
+ onClose: () => void;
+ onResolveMoney: () => void;
+}
+
+export default function DenyRequestModal({
+ requestId,
+ propertyId,
+ currencyCode,
+ unresolvedAmount,
+ onClose,
+ onResolveMoney,
+}: DenyRequestModalProps) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const [reason, setReason] = useState('');
+ const hasUnresolvedMoney = unresolvedAmount > 0.000001;
+ const mutation = useMutation({
+ mutationFn: () => api.post(
+ `/v1/booking-requests/${requestId}/deny`,
+ { reason: reason.trim() },
+ { params: { propertyId } },
+ ),
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.root(propertyId) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.payments(propertyId, requestId) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.messages(propertyId, requestId) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.audit(propertyId, requestId) }),
+ ]);
+ onClose();
+ },
+ });
+
+ return (
+
+ {hasUnresolvedMoney ? (
+
+
+ {t('bookingRequests.deny.unresolved', {
+ amount: formatMoney(unresolvedAmount, currencyCode),
+ })}
+
+
{t('bookingRequests.deny.unresolvedDirection')}
+
+ {t('bookingRequests.deny.resolveFirst')}
+
+
+ ) : null}
+
+
+ {t('bookingRequests.deny.reason')}
+ setReason(event.target.value)}
+ rows={4}
+ className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-telivity-navy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue"
+ />
+
+
+ {mutation.isError ? (
+
+ {apiErrorMessage(mutation.error, t('bookingRequests.deny.error'))}
+
+ ) : null}
+
+
+
+ {t('bookingRequests.common.cancel')}
+
+ mutation.mutate()}
+ disabled={hasUnresolvedMoney || !reason.trim() || mutation.isPending}
+ className="rounded-lg bg-telivity-orange px-4 py-2 text-sm font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-orange focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ {mutation.isPending ? t('bookingRequests.deny.denying') : t('bookingRequests.deny.confirm')}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/booking-requests/ModifyStayModal.test.tsx b/apps/dashboard/src/components/booking-requests/ModifyStayModal.test.tsx
new file mode 100644
index 00000000..b56a9d2b
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/ModifyStayModal.test.tsx
@@ -0,0 +1,157 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import ModifyStayModal from './ModifyStayModal';
+import type { BookingRequestDetail } from './types';
+
+vi.mock('../../lib/api', () => ({
+ api: { get: vi.fn(), post: vi.fn() },
+}));
+
+import { api } from '../../lib/api';
+
+const request = {
+ id: 'request-1',
+ propertyId: 'property-1',
+ status: 'accepted',
+ arrivalDate: '2026-10-01',
+ departureDate: '2026-10-03',
+ roomTypeId: 'room-type-1',
+ ratePlanId: 'rate-plan-1',
+ adults: 2,
+ children: 0,
+ guestFirstName: 'Ada',
+ guestLastName: 'Lovelace',
+ guestEmail: 'ada@example.com',
+ submittedTotal: '220.00',
+ currencyCode: 'EUR',
+ acceptedPriceSource: 'current',
+ acceptedTotal: '220.00',
+ acceptedReservationId: 'reservation-1',
+ createdAt: '2026-08-24T09:00:00.000Z',
+ updatedAt: '2026-08-24T10:00:00.000Z',
+ guestPhone: null,
+ specialRequests: null,
+ serviceIds: [],
+ formSnapshot: [],
+ applicationAnswers: {},
+ submittedQuoteSnapshot: { currencyCode: 'EUR', grandTotal: '220.00' },
+ currentQuoteSnapshot: { currencyCode: 'EUR', grandTotal: '220.00' },
+ card: null,
+ customPriceReason: null,
+ acceptedFolioId: 'folio-1',
+ decidedBy: 'staff-1',
+ decidedAt: '2026-08-24T10:00:00.000Z',
+ denialReason: null,
+ operationalReservation: {
+ id: 'reservation-1',
+ arrivalDate: '2026-10-01',
+ departureDate: '2026-10-03',
+ totalAmount: '220.00',
+ currencyCode: 'EUR',
+ roomTypeId: 'room-type-1',
+ ratePlanId: 'rate-plan-1',
+ status: 'confirmed',
+ updatedAt: '2026-08-25T10:00:00.000Z',
+ },
+} satisfies BookingRequestDetail;
+
+function renderModal() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ const onClose = vi.fn();
+ render(
+
+
+ ,
+ );
+ return { queryClient, onClose };
+}
+
+describe('ModifyStayModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(api.get).mockResolvedValue({
+ data: {
+ requestId: 'request-1',
+ reservationId: 'reservation-1',
+ previousArrivalDate: '2026-10-01',
+ previousDepartureDate: '2026-10-03',
+ previousTotal: '220.00',
+ arrivalDate: '2026-10-01',
+ departureDate: '2026-10-03',
+ priorTotal: '220.00',
+ currentTotal: '240.00',
+ currencyCode: 'EUR',
+ previewVersion: 1,
+ previewToken: `v1:${'a'.repeat(64)}`,
+ },
+ } as never);
+ vi.mocked(api.post).mockResolvedValue({ data: {} } as never);
+ });
+
+ it('shows the exact custom total in the proposed stay rail and submits its reason', async () => {
+ renderModal();
+ const dialog = screen.getByRole('dialog', { name: 'Modify accepted stay' });
+ await within(dialog).findByText('€240.00');
+
+ await userEvent.click(within(dialog).getByRole('radio', { name: /Custom total/i }));
+ await userEvent.type(within(dialog).getByRole('textbox', { name: 'Custom total' }), '235');
+ await userEvent.type(
+ within(dialog).getByRole('textbox', { name: 'Reason for custom total' }),
+ 'Signed offer',
+ );
+
+ const proposedStay = within(dialog).getByText('Proposed stay').parentElement!;
+ expect(within(proposedStay).getByText('€235.00')).toBeInTheDocument();
+
+ await userEvent.click(within(dialog).getByRole('button', { name: 'Apply stay change' }));
+ await waitFor(() => expect(api.post).toHaveBeenCalledWith(
+ '/v1/booking-requests/request-1/stay-amendments',
+ expect.objectContaining({
+ priceSource: 'custom',
+ customTotal: '235.00',
+ customReason: 'Signed offer',
+ }),
+ { params: { propertyId: 'property-1' } },
+ ));
+ });
+
+ it('connects custom-money errors and required reason semantics to their controls', async () => {
+ renderModal();
+ const dialog = screen.getByRole('dialog', { name: 'Modify accepted stay' });
+ await within(dialog).findByText('€240.00');
+ await userEvent.click(within(dialog).getByRole('radio', { name: /Custom total/i }));
+
+ const total = within(dialog).getByRole('textbox', { name: 'Custom total' });
+ await userEvent.type(total, '1.234');
+
+ expect(total).toHaveAttribute('aria-invalid', 'true');
+ expect(total).toHaveAccessibleDescription(
+ 'Use only the minor units supported by this currency.',
+ );
+ expect(within(dialog).getByRole('textbox', { name: 'Reason for custom total' }))
+ .toBeRequired();
+ });
+
+ it('invalidates every existing reservation query family after an amendment', async () => {
+ const { queryClient } = renderModal();
+ queryClient.setQueryData(['reservations', { propertyId: 'property-1', page: 1 }], []);
+ queryClient.setQueryData(['reservations', 'arrivals', 'property-1', '2026-10-01'], []);
+ const dialog = screen.getByRole('dialog', { name: 'Modify accepted stay' });
+ await within(dialog).findByText('€240.00');
+
+ await userEvent.click(within(dialog).getByRole('button', { name: 'Apply stay change' }));
+
+ await waitFor(() => {
+ expect(queryClient.getQueryState(
+ ['reservations', { propertyId: 'property-1', page: 1 }],
+ )?.isInvalidated).toBe(true);
+ expect(queryClient.getQueryState(
+ ['reservations', 'arrivals', 'property-1', '2026-10-01'],
+ )?.isInvalidated).toBe(true);
+ });
+ });
+});
diff --git a/apps/dashboard/src/components/booking-requests/ModifyStayModal.tsx b/apps/dashboard/src/components/booking-requests/ModifyStayModal.tsx
new file mode 100644
index 00000000..e54c96df
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/ModifyStayModal.tsx
@@ -0,0 +1,306 @@
+import { useId, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { ArrowRight, CalendarRange } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { api } from '../../lib/api';
+import { formatMoney } from '../../lib/money';
+import Modal from '../ui/Modal';
+import { validateMoneyInput } from './moneyInput';
+import { bookingRequestKeys } from './queryKeys';
+import {
+ apiErrorMessage,
+ type BookingRequestDetail,
+ type BookingRequestStayAmendmentPreview,
+ type StayAmendmentPriceSource,
+} from './types';
+
+type ModifyStayModalProps = {
+ request: BookingRequestDetail;
+ propertyId: string;
+ onClose: () => void;
+};
+
+function newIdempotencyKey(requestId: string): string {
+ const suffix = globalThis.crypto?.randomUUID?.()
+ ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ return `stay-amendment:${requestId}:${suffix}`;
+}
+
+export default function ModifyStayModal({
+ request,
+ propertyId,
+ onClose,
+}: ModifyStayModalProps) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const customTotalErrorId = useId();
+ const operational = request.operationalReservation!;
+ const [arrivalDate, setArrivalDate] = useState(operational.arrivalDate);
+ const [departureDate, setDepartureDate] = useState(operational.departureDate);
+ const [priceSource, setPriceSource] = useState('prior');
+ const [customTotal, setCustomTotal] = useState('');
+ const [customReason, setCustomReason] = useState('');
+ const [idempotencyKey] = useState(() => newIdempotencyKey(request.id));
+ const datesValid = /^\d{4}-\d{2}-\d{2}$/.test(arrivalDate)
+ && /^\d{4}-\d{2}-\d{2}$/.test(departureDate)
+ && departureDate > arrivalDate;
+
+ const previewQuery = useQuery({
+ queryKey: bookingRequestKeys.stayAmendmentPreview(
+ propertyId,
+ request.id,
+ arrivalDate,
+ departureDate,
+ ),
+ queryFn: () => api.get(
+ `/v1/booking-requests/${request.id}/stay-amendment-preview`,
+ { params: { propertyId, arrivalDate, departureDate } },
+ ).then((response) => response.data?.data ?? response.data),
+ enabled: datesValid,
+ });
+ const candidate = previewQuery.data as BookingRequestStayAmendmentPreview | undefined;
+ const preview = candidate?.requestId === request.id
+ && candidate.reservationId === operational.id
+ && candidate.arrivalDate === arrivalDate
+ && candidate.departureDate === departureDate
+ && candidate.currencyCode === operational.currencyCode
+ ? candidate
+ : undefined;
+ const customValidation = validateMoneyInput(customTotal, operational.currencyCode);
+ const customReady = priceSource !== 'custom'
+ || (customValidation.canonical != null && customReason.trim().length > 0);
+ const customError = priceSource === 'custom'
+ && customTotal !== ''
+ && customValidation.error != null;
+
+ const mutation = useMutation({
+ mutationFn: () => api.post(
+ `/v1/booking-requests/${request.id}/stay-amendments`,
+ {
+ arrivalDate,
+ departureDate,
+ priceSource,
+ previewToken: preview?.previewToken,
+ idempotencyKey,
+ ...(priceSource === 'custom' ? {
+ customTotal: customValidation.canonical!,
+ customReason: customReason.trim(),
+ } : {}),
+ },
+ { params: { propertyId } },
+ ),
+ onError: async (error) => {
+ if ((error as { response?: { status?: number } })?.response?.status === 409) {
+ await previewQuery.refetch();
+ }
+ },
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.root(propertyId) }),
+ queryClient.invalidateQueries({ queryKey: ['reservations'] }),
+ queryClient.invalidateQueries({ queryKey: ['availability', propertyId] }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.genericFoliosRoot(propertyId) }),
+ queryClient.invalidateQueries({
+ queryKey: bookingRequestKeys.folioWorkspace(
+ propertyId,
+ request.id,
+ operational.id,
+ ),
+ }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.payments(propertyId, request.id) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.audit(propertyId, request.id) }),
+ ]);
+ onClose();
+ },
+ });
+
+ const options: Array<{
+ value: StayAmendmentPriceSource;
+ label: string;
+ amount: string;
+ description: string;
+ }> = [
+ {
+ value: 'prior',
+ label: t('bookingRequests.modifyStay.prior'),
+ amount: preview
+ ? formatMoney(preview.priorTotal, operational.currencyCode)
+ : t('bookingRequests.modifyStay.awaitingQuote'),
+ description: t('bookingRequests.modifyStay.priorDescription'),
+ },
+ {
+ value: 'current',
+ label: t('bookingRequests.modifyStay.current'),
+ amount: preview
+ ? formatMoney(preview.currentTotal, operational.currencyCode)
+ : t('bookingRequests.modifyStay.awaitingQuote'),
+ description: t('bookingRequests.modifyStay.currentDescription'),
+ },
+ {
+ value: 'custom',
+ label: t('bookingRequests.modifyStay.custom'),
+ amount: t('bookingRequests.modifyStay.enterAmount'),
+ description: t('bookingRequests.modifyStay.customDescription'),
+ },
+ ];
+
+ return (
+
+
+
+
+ {t('bookingRequests.modifyStay.activeStay')}
+
+
+ {operational.arrivalDate} → {operational.departureDate}
+
+
+ {formatMoney(operational.totalAmount, operational.currencyCode)}
+
+
+
+
+
+ {t('bookingRequests.modifyStay.proposedStay')}
+
+
{arrivalDate} → {departureDate}
+
+ {preview && priceSource === 'custom' && customValidation.canonical
+ ? formatMoney(customValidation.canonical, preview.currencyCode)
+ : preview && priceSource !== 'custom'
+ ? formatMoney(
+ priceSource === 'current' ? preview.currentTotal : preview.priorTotal,
+ preview.currencyCode,
+ )
+ : t('bookingRequests.modifyStay.awaitingQuote')}
+
+
+
+
+
+
+ {t('bookingRequests.modifyStay.arrivalDate')}
+ setArrivalDate(event.target.value)}
+ className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-telivity-navy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue"
+ />
+
+
+ {t('bookingRequests.modifyStay.departureDate')}
+ setDepartureDate(event.target.value)}
+ className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-telivity-navy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue"
+ />
+
+
+ {!datesValid ? (
+
+ {t('bookingRequests.modifyStay.invalidDates')}
+
+ ) : previewQuery.isLoading || previewQuery.isFetching ? (
+
+ {t('bookingRequests.modifyStay.checking')}
+
+ ) : previewQuery.isError || !preview ? (
+
+ {t('bookingRequests.modifyStay.previewError')}
+ previewQuery.refetch()} className="font-semibold text-telivity-deep-blue underline underline-offset-2">
+ {t('bookingRequests.common.retry')}
+
+
+ ) : null}
+
+
+
+ {t('bookingRequests.modifyStay.priceChoice')}
+
+ {options.map((option) => (
+
+ setPriceSource(option.value)}
+ className="mt-1 text-telivity-deep-blue focus:ring-telivity-deep-blue"
+ />
+
+
+ {option.label} {option.amount}
+
+ {option.description}
+
+
+ ))}
+
+
+ {priceSource === 'custom' ? (
+
+
+ {t('bookingRequests.modifyStay.customTotal')}
+ setCustomTotal(event.target.value)}
+ className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-telivity-navy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue"
+ />
+ {customError ? (
+
+ {t(`bookingRequests.validation.${customValidation.error}`)}
+
+ ) : null}
+
+
+ {t('bookingRequests.modifyStay.customReason')}
+ setCustomReason(event.target.value)}
+ className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-telivity-navy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue"
+ />
+
+
+ ) : null}
+
+ {mutation.isError ? (
+
+ {apiErrorMessage(mutation.error, t('bookingRequests.modifyStay.commitError'))}
+
+ ) : null}
+
+
+
+ {t('bookingRequests.common.cancel')}
+
+ mutation.mutate()}
+ disabled={!preview || !customReady || mutation.isPending}
+ className="inline-flex items-center justify-center gap-2 rounded-lg bg-telivity-deep-blue px-4 py-2 text-sm font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+
+ {mutation.isPending
+ ? t('bookingRequests.modifyStay.applying')
+ : t('bookingRequests.modifyStay.apply')}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/booking-requests/PaymentActionModal.tsx b/apps/dashboard/src/components/booking-requests/PaymentActionModal.tsx
new file mode 100644
index 00000000..d8e90840
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/PaymentActionModal.tsx
@@ -0,0 +1,202 @@
+import { useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { api } from '../../lib/api';
+import Modal from '../ui/Modal';
+import { bookingRequestKeys } from './queryKeys';
+import { validateMoneyInput } from './moneyInput';
+import { apiErrorMessage, type BookingRequestPayment } from './types';
+
+export type PaymentAction = 'charge' | 'external' | 'refund' | 'external_return' | 'retain';
+
+interface PaymentActionModalProps {
+ action: PaymentAction;
+ requestId: string;
+ propertyId: string;
+ currencyCode: string;
+ reservationId: string | null;
+ payment?: BookingRequestPayment;
+ initialAmount?: string;
+ onClose: () => void;
+}
+
+function newIdentity(): string {
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+}
+
+export function localDateTimeInputValue(date: Date): string {
+ const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
+ return local.toISOString().slice(0, 16);
+}
+
+export default function PaymentActionModal({
+ action,
+ requestId,
+ propertyId,
+ currencyCode,
+ reservationId,
+ payment,
+ initialAmount = '',
+ onClose,
+}: PaymentActionModalProps) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const [amount, setAmount] = useState(initialAmount);
+ const [method, setMethod] = useState('cash');
+ const [processedAt, setProcessedAt] = useState(() => localDateTimeInputValue(new Date()));
+ const [provider, setProvider] = useState('');
+ const [reference, setReference] = useState('');
+ const [notes, setNotes] = useState('');
+ const [reason, setReason] = useState('');
+ const [idempotencyKey] = useState(newIdentity);
+ const amountValidation = validateMoneyInput(amount, currencyCode);
+ const hasAmountError = amount !== '' && amountValidation.error != null;
+
+ const title = t(`bookingRequests.paymentActions.${action}.title`);
+ const actionLabel = t(`bookingRequests.paymentActions.${action}.action`);
+ const needsReference = action === 'external' || action === 'external_return';
+ const isReady = amountValidation.canonical != null
+ && (!needsReference || reference.trim().length > 0)
+ && (action !== 'retain' || reason.trim().length > 0);
+
+ const mutation = useMutation({
+ mutationFn: () => {
+ const base = `/v1/booking-requests/${requestId}/payments`;
+ const config = { params: { propertyId } };
+ if (action === 'charge') {
+ return api.post(`${base}/charge`, { amount: amountValidation.canonical!, idempotencyKey }, config);
+ }
+ if (action === 'external') {
+ return api.post(`${base}/external`, {
+ amount: amountValidation.canonical!,
+ currencyCode,
+ method,
+ processedAt: new Date(processedAt).toISOString(),
+ ...(provider.trim() ? { provider: provider.trim() } : {}),
+ reference: reference.trim(),
+ ...(notes.trim() ? { notes: notes.trim() } : {}),
+ }, config);
+ }
+ if (!payment) throw new Error(t('bookingRequests.paymentActions.paymentRequired'));
+ if (action === 'refund') {
+ return api.post(`${base}/${payment.id}/refunds`, {
+ amount: amountValidation.canonical!,
+ idempotencyKey,
+ }, config);
+ }
+ if (action === 'external_return') {
+ return api.post(`${base}/${payment.id}/external-returns`, {
+ amount: amountValidation.canonical!,
+ processedAt: new Date(processedAt).toISOString(),
+ reference: reference.trim(),
+ ...(notes.trim() ? { notes: notes.trim() } : {}),
+ }, config);
+ }
+ return api.post(`${base}/${payment.id}/retentions`, {
+ amount: amountValidation.canonical!,
+ reason: reason.trim(),
+ }, config);
+ },
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.root(propertyId) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.payments(propertyId, requestId) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.installments(propertyId, requestId) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.messages(propertyId, requestId) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.audit(propertyId, requestId) }),
+ queryClient.invalidateQueries({
+ queryKey: bookingRequestKeys.folioWorkspace(propertyId, requestId, reservationId),
+ }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.genericFoliosRoot(propertyId) }),
+ ]);
+ onClose();
+ },
+ });
+
+ return (
+
+
+ {t(`bookingRequests.paymentActions.${action}.description`)}
+
+
+
+
+ {t('bookingRequests.paymentActions.amount')}
+ setAmount(event.target.value)}
+ className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-telivity-navy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue"
+ />
+ {hasAmountError ? (
+
+ {t(`bookingRequests.validation.${amountValidation.error}`)}
+
+ ) : null}
+
+
+ {action === 'external' ? (
+ <>
+
+ {t('bookingRequests.paymentActions.method')}
+ setMethod(event.target.value)}
+ className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-telivity-navy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue"
+ >
+ {['credit_card', 'debit_card', 'cash', 'bank_transfer', 'pix', 'other'].map((value) => (
+ {t(`bookingRequests.methods.${value}`)}
+ ))}
+
+
+
+ {t('bookingRequests.paymentActions.provider')}
+ setProvider(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue" />
+
+ >
+ ) : null}
+
+ {(action === 'external' || action === 'external_return') ? (
+ <>
+
+ {t('bookingRequests.paymentActions.processedAt')}
+ setProcessedAt(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue" />
+
+
+ {t('bookingRequests.paymentActions.reference')}
+ setReference(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue" />
+
+
+ {t('bookingRequests.paymentActions.notes')}
+ setNotes(event.target.value)} rows={2} className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue" />
+
+ >
+ ) : null}
+
+ {action === 'retain' ? (
+
+ {t('bookingRequests.paymentActions.retain.reason')}
+ setReason(event.target.value)} rows={3} className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue" />
+
+ ) : null}
+
+
+ {mutation.isError ? (
+
+ {apiErrorMessage(mutation.error, t('bookingRequests.paymentActions.error'))}
+
+ ) : null}
+
+
+
+ {t('bookingRequests.common.cancel')}
+
+ mutation.mutate()} disabled={!isReady || mutation.isPending} className="rounded-lg bg-telivity-deep-blue px-4 py-2 text-sm font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
+ {mutation.isPending ? t('bookingRequests.paymentActions.saving') : actionLabel}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/booking-requests/RequestAudit.tsx b/apps/dashboard/src/components/booking-requests/RequestAudit.tsx
new file mode 100644
index 00000000..726dcff5
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/RequestAudit.tsx
@@ -0,0 +1,165 @@
+import { useMemo } from 'react';
+import { CheckCircle2, CircleDollarSign, FileCheck2, Mail } from 'lucide-react';
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { api } from '../../lib/api';
+import { formatMoney } from '../../lib/money';
+import { bookingRequestKeys } from './queryKeys';
+import type {
+ BookingRequestAuditHistoryItem,
+ BookingRequestDetail,
+} from './types';
+
+function auditIcon(summary: string) {
+ if (summary.startsWith('payment.') || summary.startsWith('resolution.') || summary.startsWith('allocation.')) {
+ return CircleDollarSign;
+ }
+ if (summary.startsWith('email.')) return Mail;
+ if (summary === 'request.accepted' || summary === 'request.denied') return CheckCircle2;
+ return FileCheck2;
+}
+
+export default function RequestAudit({
+ request,
+ propertyId,
+}: {
+ request: BookingRequestDetail;
+ propertyId: string;
+}) {
+ const { t, i18n } = useTranslation();
+ const dateFormatter = useMemo(() => new Intl.DateTimeFormat(i18n.language, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }), [i18n.language]);
+ const historyQuery = useInfiniteQuery<{
+ data: BookingRequestAuditHistoryItem[];
+ nextCursor: string | null;
+ }>({
+ queryKey: bookingRequestKeys.audit(propertyId, request.id),
+ initialPageParam: null,
+ queryFn: ({ pageParam }) => api.get(
+ `/v1/booking-requests/${request.id}/audit-history`,
+ {
+ params: {
+ propertyId,
+ limit: 25,
+ ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
+ },
+ },
+ ).then((response) => {
+ const envelope = response.data;
+ const payload = envelope?.data ?? envelope;
+ if (Array.isArray(payload)) {
+ return {
+ data: payload,
+ nextCursor: typeof envelope?.nextCursor === 'string'
+ ? envelope.nextCursor
+ : null,
+ };
+ }
+ return {
+ data: payload?.data ?? [],
+ nextCursor: typeof payload?.nextCursor === 'string'
+ ? payload.nextCursor
+ : null,
+ };
+ }),
+ getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
+ });
+ const entries = useMemo(() => {
+ const seen = new Set();
+ const uniqueEntries: BookingRequestAuditHistoryItem[] = [];
+ for (const page of historyQuery.data?.pages ?? []) {
+ for (const entry of page.data) {
+ const identity = `${entry.source ?? 'audit_log'}:${entry.id}`;
+ if (seen.has(identity)) continue;
+ seen.add(identity);
+ uniqueEntries.push(entry);
+ }
+ }
+ return uniqueEntries;
+ }, [historyQuery.data?.pages]);
+
+ const amountFor = (entry: BookingRequestAuditHistoryItem) => {
+ const amount = entry.details['acceptedTotal']
+ ?? entry.details['amount']
+ ?? entry.details['fixedAmount'];
+ return typeof amount === 'string' || typeof amount === 'number'
+ ? formatMoney(amount, request.currencyCode)
+ : null;
+ };
+
+ return (
+
+ {t('bookingRequests.audit.title')}
+ {t('bookingRequests.audit.description')}
+
+ {historyQuery.isLoading ? (
+ {t('bookingRequests.common.loading')}
+ ) : historyQuery.isError && entries.length === 0 ? (
+
+ {t('bookingRequests.audit.loadError')}
+ historyQuery.refetch()} className="font-semibold text-telivity-deep-blue underline underline-offset-2">
+ {t('bookingRequests.common.retry')}
+
+
+ ) : entries.length === 0 ? (
+ {t('bookingRequests.audit.empty')}
+ ) : (
+ <>
+
+ {entries.map((entry, index) => {
+ const Icon = auditIcon(entry.summary);
+ const amount = amountFor(entry);
+ const label = entry.details['label'];
+ return (
+
+ {index < entries.length - 1 ? : null}
+
+
+
+
+
+
+ {t(`bookingRequests.audit.events.${entry.summary.replace('.', '_')}`)}
+
+ {dateFormatter.format(new Date(entry.occurredAt))}
+
+ {amount || typeof label === 'string' ? (
+
+ {[typeof label === 'string' ? label : null, amount].filter(Boolean).join(' · ')}
+
+ ) : null}
+
+ {t('bookingRequests.audit.actor', { actor: entry.actorDisplay })}
+
+
+
+ );
+ })}
+
+ {historyQuery.hasNextPage ? (
+ historyQuery.fetchNextPage()}
+ disabled={historyQuery.isFetchingNextPage}
+ className="mt-4 rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-telivity-deep-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue disabled:opacity-50"
+ >
+ {historyQuery.isFetchingNextPage
+ ? t('bookingRequests.audit.loadingMore')
+ : t('bookingRequests.audit.loadMore')}
+
+ ) : null}
+ {historyQuery.isFetchNextPageError ? (
+
+ {t('bookingRequests.audit.loadMoreError')}
+ historyQuery.fetchNextPage()} className="font-semibold underline underline-offset-2">
+ {t('bookingRequests.common.retry')}
+
+
+ ) : null}
+ >
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/components/booking-requests/RequestMessages.tsx b/apps/dashboard/src/components/booking-requests/RequestMessages.tsx
new file mode 100644
index 00000000..b5a8f2d5
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/RequestMessages.tsx
@@ -0,0 +1,117 @@
+import { useMemo, useState } from 'react';
+import { Mail, RotateCcw } from 'lucide-react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { api } from '../../lib/api';
+import StatusBadge from '../ui/StatusBadge';
+import { bookingRequestKeys } from './queryKeys';
+import { apiErrorMessage, type BookingRequestEmailDelivery } from './types';
+
+interface RequestMessagesProps {
+ requestId: string;
+ propertyId: string;
+ canWrite: boolean;
+}
+
+export default function RequestMessages({ requestId, propertyId, canWrite }: RequestMessagesProps) {
+ const { t, i18n } = useTranslation();
+ const queryClient = useQueryClient();
+ const [pendingDeliveryIds, setPendingDeliveryIds] = useState>(() => new Set());
+ const dateFormatter = useMemo(() => new Intl.DateTimeFormat(i18n.language, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }), [i18n.language]);
+ const { data, isLoading, isError } = useQuery({
+ queryKey: bookingRequestKeys.messages(propertyId, requestId),
+ queryFn: () => api.get(
+ `/v1/booking-requests/${requestId}/emails`,
+ { params: { propertyId } },
+ ).then((response) => response.data?.data ?? response.data ?? []),
+ });
+ const deliveries = Array.isArray(data) ? data as BookingRequestEmailDelivery[] : [];
+
+ const retry = useMutation({
+ mutationFn: (deliveryId: string) => api.post(
+ `/v1/booking-requests/${requestId}/emails/${deliveryId}/retry`,
+ undefined,
+ { params: { propertyId } },
+ ),
+ onMutate: (deliveryId) => {
+ setPendingDeliveryIds((current) => new Set(current).add(deliveryId));
+ },
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.messages(propertyId, requestId) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.audit(propertyId, requestId) }),
+ ]);
+ },
+ onSettled: (_data, _error, deliveryId) => {
+ setPendingDeliveryIds((current) => {
+ const next = new Set(current);
+ next.delete(deliveryId);
+ return next;
+ });
+ },
+ });
+ const formatDate = (value: string | null) => value
+ ? dateFormatter.format(new Date(value))
+ : '—';
+
+ if (isLoading) return {t('bookingRequests.messages.loading')}
;
+ if (isError) return {t('bookingRequests.messages.loadError')}
;
+
+ return (
+
+
+
+
+ {t('bookingRequests.messages.title')}
+
+
{t('bookingRequests.messages.description')}
+
+
+ {deliveries.length ? (
+
+ ) : (
+ {t('bookingRequests.messages.empty')}
+ )}
+
+ {retry.isError ? (
+
+ {apiErrorMessage(retry.error, t('bookingRequests.messages.retryError'))}
+
+ ) : null}
+ {canWrite ? {t('bookingRequests.messages.actorNote')}
: null}
+
+ );
+}
diff --git a/apps/dashboard/src/components/booking-requests/RequestOverview.tsx b/apps/dashboard/src/components/booking-requests/RequestOverview.tsx
new file mode 100644
index 00000000..16a764f6
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/RequestOverview.tsx
@@ -0,0 +1,209 @@
+import { CalendarDays, Contact, CreditCard, FileText, Scale } from 'lucide-react';
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { formatMoney } from '../../lib/money';
+import { quoteTotal, type BookingRequestDetail } from './types';
+import ModifyStayModal from './ModifyStayModal';
+
+function displayAnswer(value: unknown, yes: string, no: string, unavailable: string): string {
+ if (typeof value === 'string' || typeof value === 'number') return String(value);
+ if (typeof value === 'boolean') return value ? yes : no;
+ if (Array.isArray(value)) {
+ const safe = value.filter((item): item is string => typeof item === 'string');
+ return safe.length ? safe.join(', ') : unavailable;
+ }
+ return unavailable;
+}
+
+function OverviewCard({
+ icon: Icon,
+ title,
+ children,
+}: {
+ icon: typeof CalendarDays;
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+export default function RequestOverview({
+ request,
+ propertyId,
+ canWrite,
+}: {
+ request: BookingRequestDetail;
+ propertyId: string;
+ canWrite: boolean;
+}) {
+ const { t } = useTranslation();
+ const [modifyOpen, setModifyOpen] = useState(false);
+ const submittedTotal = quoteTotal(request.submittedQuoteSnapshot);
+ const currentTotal = quoteTotal(request.currentQuoteSnapshot);
+ const difference = submittedTotal && currentTotal
+ ? Number(currentTotal) - Number(submittedTotal)
+ : null;
+ const questions = [...(request.formSnapshot ?? [])].sort((a, b) => a.order - b.order);
+
+ return (
+
+
+
+ {request.operationalReservation ? (
+
+
+
+ {t('bookingRequests.modifyStay.activeStay')}
+
+
+ {request.operationalReservation.arrivalDate} → {request.operationalReservation.departureDate}
+
+
+ {formatMoney(request.operationalReservation.totalAmount, request.operationalReservation.currencyCode)}
+
+
+ {canWrite && request.status === 'accepted' ? (
+
setModifyOpen(true)} className="rounded-lg border border-telivity-deep-blue px-3 py-2 text-sm font-semibold text-telivity-deep-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue">
+ {t('bookingRequests.modifyStay.action')}
+
+ ) : null}
+
+ ) : null}
+
+ {t('bookingRequests.modifyStay.originalRequest')}
+
+
+
+
{t('bookingRequests.overview.dates')}
+
+ {request.arrivalDate} → {request.departureDate}
+
+
+
+
{t('bookingRequests.overview.occupancy')}
+
+ {t('bookingRequests.overview.occupancyValue', {
+ adults: request.adults,
+ children: request.children,
+ })}
+
+
+
+
{t('bookingRequests.overview.roomType')}
+ {request.roomTypeId}
+
+
+
{t('bookingRequests.overview.ratePlan')}
+ {request.ratePlanId}
+
+
+
+
+
+
+
{t('bookingRequests.overview.email')} {request.guestEmail}
+
{t('bookingRequests.overview.phone')} {request.guestPhone || '—'}
+
+ {request.specialRequests ? (
+
+
{t('bookingRequests.overview.specialRequests')}
+
{request.specialRequests}
+
+ ) : null}
+
+
+
+ {questions.length ? (
+
+ {questions.map((question) => (
+
+
{question.label}
+
+ {displayAnswer(
+ request.applicationAnswers?.[question.id],
+ t('bookingRequests.common.yes'),
+ t('bookingRequests.common.no'),
+ t('bookingRequests.common.notProvided'),
+ )}
+
+
+ ))}
+
+ ) : (
+ {t('bookingRequests.overview.noQuestions')}
+ )}
+
+
+
+
+
+
+
+
{t('bookingRequests.amounts.submitted')}
+ {formatMoney(submittedTotal, request.currencyCode)}
+
+
+
{t('bookingRequests.amounts.current')}
+
+ {currentTotal
+ ? formatMoney(currentTotal, request.currencyCode)
+ : t('bookingRequests.accept.recheckedOnAccept')}
+
+
+ {difference != null ? (
+
+
{t('bookingRequests.amounts.difference')}
+ 0 ? 'text-telivity-orange' : 'text-telivity-dark-teal'}`}>
+ {difference > 0 ? '+' : ''}{formatMoney(difference, request.currencyCode)}
+
+
+ ) : null}
+ {request.acceptedTotal ? (
+
+
{t('bookingRequests.amounts.accepted')}
+ {formatMoney(request.acceptedTotal, request.currencyCode)}
+
+ ) : null}
+
+
+
+
+ {request.card ? (
+
+ {(request.card.brand || t('bookingRequests.overview.cardGeneric')).toUpperCase()} •••• {request.card.lastFour || '••••'}
+
+ ) : (
+ {t('bookingRequests.overview.noCard')}
+ )}
+ {t('bookingRequests.overview.cardSafety')}
+
+
+ {request.status !== 'pending' ? (
+
+
+
{t('bookingRequests.common.status')} {t(`bookingRequests.statuses.${request.status}`)}
+ {request.acceptedPriceSource ?
{t('bookingRequests.overview.priceSource')} {t(`bookingRequests.priceSources.${request.acceptedPriceSource}`)} : null}
+ {request.denialReason ?
{t('bookingRequests.deny.reason')} {request.denialReason} : null}
+ {request.customPriceReason ?
{t('bookingRequests.accept.customReason')} {request.customPriceReason} : null}
+
+
+ ) : null}
+
+ {modifyOpen && request.operationalReservation ? (
+
setModifyOpen(false)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/apps/dashboard/src/components/booking-requests/RequestPayments.tsx b/apps/dashboard/src/components/booking-requests/RequestPayments.tsx
new file mode 100644
index 00000000..c92e5a0c
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/RequestPayments.tsx
@@ -0,0 +1,506 @@
+import { useState } from 'react';
+import {
+ ArrowDownToLine,
+ Banknote,
+ CalendarClock,
+ CreditCard,
+ ChevronDown,
+ ChevronUp,
+ Pencil,
+ Plus,
+ Trash2,
+} from 'lucide-react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { api } from '../../lib/api';
+import { formatMoney } from '../../lib/money';
+import StatusBadge from '../ui/StatusBadge';
+import PaymentActionModal, { type PaymentAction } from './PaymentActionModal';
+import { fetchBookingRequestPayments } from './bookingRequestPaymentsQuery';
+import { bookingRequestKeys } from './queryKeys';
+import { validateMoneyInput, validatePercentageInput } from './moneyInput';
+import {
+ apiErrorMessage,
+ quoteTotal,
+ type BookingRequestDetail,
+ type BookingRequestInstallment,
+ type BookingRequestPayment,
+ type BookingRequestPaymentsResponse,
+ type FolioSummary,
+} from './types';
+
+interface RequestPaymentsProps {
+ request: BookingRequestDetail;
+ propertyId: string;
+ canWrite: boolean;
+}
+
+function InstallmentEditor({
+ requestId,
+ propertyId,
+ currencyCode,
+ installment,
+ nextSortOrder,
+ onClose,
+}: {
+ requestId: string;
+ propertyId: string;
+ currencyCode: string;
+ installment?: BookingRequestInstallment;
+ nextSortOrder: number;
+ onClose: () => void;
+}) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const [label, setLabel] = useState(installment?.label ?? '');
+ const [amountKind, setAmountKind] = useState<'fixed' | 'percentage'>(
+ installment?.percentage ? 'percentage' : 'fixed',
+ );
+ const [amount, setAmount] = useState(installment?.percentage ?? installment?.fixedAmount ?? '');
+ const [milestone, setMilestone] = useState(
+ installment?.dueMilestone ?? 'manual',
+ );
+ const [dueDate, setDueDate] = useState(installment?.dueDate ?? '');
+ const amountValidation = amountKind === 'fixed'
+ ? validateMoneyInput(amount, currencyCode)
+ : validatePercentageInput(amount);
+ const valid = label.trim().length > 0
+ && amountValidation.canonical != null
+ && (milestone !== 'date' || Boolean(dueDate));
+
+ const save = useMutation({
+ mutationFn: () => {
+ const payload = {
+ label: label.trim(),
+ sortOrder: installment?.sortOrder ?? nextSortOrder,
+ ...(amountKind === 'fixed'
+ ? { fixedAmount: amountValidation.canonical! }
+ : { percentage: amountValidation.canonical! }),
+ dueMilestone: milestone,
+ ...(milestone === 'date' ? { dueDate } : {}),
+ };
+ const config = { params: { propertyId } };
+ return installment
+ ? api.patch(
+ `/v1/booking-requests/${requestId}/installments/${installment.id}`,
+ payload,
+ config,
+ )
+ : api.post(`/v1/booking-requests/${requestId}/installments`, payload, config);
+ },
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: bookingRequestKeys.installments(propertyId, requestId) });
+ onClose();
+ },
+ });
+
+ return (
+
+ );
+}
+
+function AllocationEditor({
+ requestId,
+ propertyId,
+ installment,
+ payments,
+ onClose,
+}: {
+ requestId: string;
+ propertyId: string;
+ installment: BookingRequestInstallment;
+ payments: BookingRequestPayment[];
+ onClose: () => void;
+}) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const [paymentId, setPaymentId] = useState(payments[0]?.id ?? '');
+ const [amount, setAmount] = useState('');
+ const selectedPayment = payments.find((payment) => payment.id === paymentId);
+ const amountValidation = validateMoneyInput(
+ amount,
+ selectedPayment?.currencyCode ?? 'XXX',
+ );
+ const maximumAmount = Math.min(
+ Number(selectedPayment?.availableToAllocate ?? 0),
+ Math.max(0, Number(installment.resolvedAmount) - Number(installment.allocatedAmount)),
+ );
+ const mutation = useMutation({
+ mutationFn: () => api.post(
+ `/v1/booking-requests/${requestId}/installments/${installment.id}/allocations`,
+ { paymentId, amount: amountValidation.canonical! },
+ { params: { propertyId } },
+ ),
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.installments(propertyId, requestId) }),
+ queryClient.invalidateQueries({ queryKey: bookingRequestKeys.payments(propertyId, requestId) }),
+ ]);
+ onClose();
+ },
+ });
+
+ return (
+
+
{t('bookingRequests.payments.allocateTo', { label: installment.label })}
+
+
+ {t('bookingRequests.payments.movement')}
+ setPaymentId(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
+ {payments.map((payment) => (
+
+ {t('bookingRequests.payments.availableForAllocation', {
+ available: formatMoney(payment.availableToAllocate, payment.currencyCode),
+ allocated: formatMoney(payment.allocatedAmount, payment.currencyCode),
+ method: t(`bookingRequests.methods.${payment.method}`, { defaultValue: payment.method }),
+ })}
+
+ ))}
+
+
+
+ {t('bookingRequests.paymentActions.amount')}
+ setAmount(event.target.value)} className="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm" />
+ {amount !== '' && amountValidation.error ? {t(`bookingRequests.validation.${amountValidation.error}`)} : null}
+
+
+ {t('bookingRequests.common.cancel')}
+ mutation.mutate()} disabled={!paymentId || amountValidation.canonical == null || Number(amountValidation.canonical) > maximumAmount || mutation.isPending} className="rounded-lg bg-telivity-deep-blue px-3 py-2 text-sm font-semibold text-white disabled:opacity-50">{t('bookingRequests.payments.allocate')}
+
+
+ {mutation.isError ?
{apiErrorMessage(mutation.error, t('bookingRequests.payments.allocationError'))}
: null}
+
+ );
+}
+
+export default function RequestPayments({ request, propertyId, canWrite }: RequestPaymentsProps) {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const [editing, setEditing] = useState(null);
+ const [allocating, setAllocating] = useState(null);
+ const [paymentAction, setPaymentAction] = useState<{
+ action: PaymentAction;
+ payment?: BookingRequestPayment;
+ amount?: string;
+ } | null>(null);
+
+ const installmentsQuery = useQuery({
+ queryKey: bookingRequestKeys.installments(propertyId, request.id),
+ queryFn: () => api.get(`/v1/booking-requests/${request.id}/installments`, { params: { propertyId } })
+ .then((response) => response.data?.data ?? response.data ?? []),
+ });
+ const paymentsQuery = useQuery({
+ queryKey: bookingRequestKeys.payments(propertyId, request.id),
+ queryFn: () => fetchBookingRequestPayments(request.id, propertyId),
+ });
+ const folioQuery = useQuery({
+ queryKey: request.acceptedFolioId
+ ? bookingRequestKeys.folio(
+ propertyId,
+ request.id,
+ request.acceptedReservationId,
+ request.acceptedFolioId,
+ )
+ : bookingRequestKeys.folioWorkspace(propertyId, request.id, null),
+ queryFn: () => api.get(`/v1/folios/${request.acceptedFolioId}`, { params: { propertyId } })
+ .then((response) => response.data?.data ?? response.data),
+ enabled: Boolean(request.acceptedFolioId),
+ });
+
+ const installments = (Array.isArray(installmentsQuery.data)
+ ? installmentsQuery.data as BookingRequestInstallment[]
+ : []).filter((item) => item.propertyId === propertyId && item.bookingRequestId === request.id)
+ .sort((left, right) => left.sortOrder - right.sortOrder);
+ const rawPayments = paymentsQuery.data as BookingRequestPaymentsResponse | undefined;
+ const payments: BookingRequestPaymentsResponse = {
+ movements: (rawPayments?.movements ?? []).filter((item) => item.propertyId === propertyId && item.bookingRequestId === request.id),
+ allocations: (rawPayments?.allocations ?? []).filter((item) => item.propertyId === propertyId && item.bookingRequestId === request.id),
+ resolutions: (rawPayments?.resolutions ?? []).filter((item) => item.propertyId === propertyId && item.bookingRequestId === request.id),
+ };
+ const allocationsKnown = paymentsQuery.isSuccess && Array.isArray(rawPayments?.allocations);
+ const durableAllocationByInstallment = new Map();
+ if (allocationsKnown) {
+ for (const allocation of payments.allocations) {
+ durableAllocationByInstallment.set(
+ allocation.installmentId,
+ (durableAllocationByInstallment.get(allocation.installmentId) ?? 0) + Number(allocation.amount),
+ );
+ }
+ }
+ const folio = folioQuery.data as FolioSummary | undefined;
+ const originalCaptured = payments.movements.filter((movement) =>
+ !movement.originalPaymentId
+ && ['captured', 'settled', 'partially_refunded', 'refunded'].includes(movement.status)
+ && Number(movement.amount) > 0);
+ const allocatablePayments = originalCaptured.filter((movement) =>
+ Number(movement.availableToAllocate) > 0);
+ const captured = originalCaptured.reduce((sum, movement) => sum + Number(movement.netCapturedAmount), 0);
+ const returned = originalCaptured.reduce((sum, movement) => sum + Number(movement.returnedAmount), 0);
+ const retained = originalCaptured.reduce((sum, movement) => sum + Number(movement.retainedAmount), 0);
+ const availableResolutionByPayment = new Map(originalCaptured.map((payment) => [
+ payment.id,
+ Number(payment.availableToResolve),
+ ]));
+ const requestedTotal = quoteTotal(request.submittedQuoteSnapshot);
+
+ const remove = useMutation({
+ mutationFn: (installmentId: string) => api.delete(
+ `/v1/booking-requests/${request.id}/installments/${installmentId}`,
+ { params: { propertyId } },
+ ),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: bookingRequestKeys.installments(propertyId, request.id) }),
+ });
+ const reorder = useMutation({
+ mutationFn: async ({ from, to }: { from: number; to: number }) => {
+ const next = [...installments];
+ const [moved] = next.splice(from, 1);
+ if (!moved) return;
+ next.splice(to, 0, moved);
+ await api.patch(
+ `/v1/booking-requests/${request.id}/installments/reorder`,
+ { installmentIds: next.map((installment) => installment.id) },
+ { params: { propertyId } },
+ );
+ },
+ onSuccess: () => queryClient.invalidateQueries({
+ queryKey: bookingRequestKeys.installments(propertyId, request.id),
+ }),
+ });
+
+ const milestoneLabel = (installment: BookingRequestInstallment) => installment.dueMilestone === 'date'
+ ? t('bookingRequests.milestones.dateValue', { date: installment.dueDate })
+ : t(`bookingRequests.milestones.${installment.dueMilestone}`);
+ const provenance = (payment: BookingRequestPayment) => payment.source === 'saved_card'
+ ? t('bookingRequests.payments.savedCardProvenance', {
+ brand: payment.cardBrand || t('bookingRequests.overview.cardGeneric'),
+ lastFour: payment.cardLastFour || '••••',
+ })
+ : t('bookingRequests.payments.externalProvenance', {
+ method: t(`bookingRequests.methods.${payment.method}`, { defaultValue: payment.method }),
+ reference: payment.reference || '—',
+ });
+
+ if (installmentsQuery.isError || paymentsQuery.isError) {
+ return {t('bookingRequests.payments.loadError')}
;
+ }
+
+ return (
+
+
+
+
+
{t('bookingRequests.payments.requestSummary')}
+
{t('bookingRequests.payments.independence')}
+
+ {canWrite && request.status !== 'denied' ? (
+
+ {request.card ? (
+ setPaymentAction({ action: 'charge' })} className="inline-flex items-center justify-center gap-2 rounded-lg bg-telivity-deep-blue px-4 py-2 text-sm font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue focus-visible:ring-offset-2">
+ {t('bookingRequests.paymentActions.charge.action')}
+
+ ) : null}
+ setPaymentAction({ action: 'external' })} className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-telivity-deep-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue">
+ {t('bookingRequests.paymentActions.external.action')}
+
+
+ ) : null}
+
+
+ {[
+ [t('bookingRequests.amounts.submitted'), formatMoney(requestedTotal, request.currencyCode)],
+ [t('bookingRequests.amounts.captured'), formatMoney(captured, request.currencyCode)],
+ [t('bookingRequests.amounts.returned'), formatMoney(returned, request.currencyCode)],
+ [t('bookingRequests.amounts.retained'), formatMoney(retained, request.currencyCode)],
+ ].map(([label, value]) => (
+
+
{label}
+ {value}
+
+ ))}
+
+
+
+ {request.status === 'accepted' && request.acceptedFolioId ? (
+
+
+
+
{t('bookingRequests.payments.folioSummary')}
+
+ {folioQuery.isLoading ? {t('bookingRequests.common.loading')}
: folio ? (
+
+
{t('bookingRequests.payments.acceptedDeal')} {formatMoney(request.acceptedTotal, request.currencyCode)}
+
{t('bookingRequests.payments.activeStayTotal')} {formatMoney(request.operationalReservation?.totalAmount, request.operationalReservation?.currencyCode ?? request.currencyCode)}
+
{t('bookingRequests.payments.folioCharges')} {formatMoney(folio.totalCharges, folio.currencyCode)}
+
{t('bookingRequests.payments.folioPayments')} {formatMoney(folio.totalPayments, folio.currencyCode)}
+
{t('bookingRequests.payments.balanceDue')} {formatMoney(folio.balance, folio.currencyCode)}
+
+ ) : {t('bookingRequests.payments.folioError')}
}
+
+ ) : null}
+
+
+
+
+
{t('bookingRequests.payments.plan')}
+
+ {t('bookingRequests.payments.noAutomaticTitle')} {' '}
+ {t('bookingRequests.payments.noAutomaticDescription')}
+
+
+ {canWrite && request.status !== 'denied' ? (
+
setEditing('new')} className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold text-telivity-deep-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue"> {t('bookingRequests.payments.addInstallment')}
+ ) : null}
+
+
+ {editing ? (
+
+ setEditing(null)} />
+
+ ) : null}
+
+
+ {installments.map((installment, index) => {
+ const resolved = Number(installment.resolvedAmount);
+ const durableAllocation = allocationsKnown
+ ? durableAllocationByInstallment.get(installment.id) ?? 0
+ : null;
+ const isPartial = durableAllocation != null
+ && durableAllocation > 0
+ && durableAllocation < resolved;
+ const canRemove = durableAllocation != null && durableAllocation < resolved;
+ const remainingPaid = durableAllocation == null
+ ? null
+ : formatMoney(durableAllocation.toFixed(2), request.currencyCode);
+ return (
+
+
+
+
+
{installment.label}
+
+
+
{milestoneLabel(installment)}
+
+ {t('bookingRequests.payments.allocated', {
+ allocated: formatMoney(installment.allocatedAmount, request.currencyCode),
+ total: formatMoney(installment.resolvedAmount, request.currencyCode),
+ })}
+
+
+ {canWrite && request.status !== 'denied' ? (
+
+ {allocatablePayments.length && Number(installment.allocatedAmount) < Number(installment.resolvedAmount) ?
setAllocating(installment)} className="rounded-lg border border-slate-300 p-2 text-telivity-deep-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue"> : null}
+
reorder.mutate({ from: index, to: index - 1 })} disabled={index === 0 || reorder.isPending} className="rounded-lg border border-slate-300 p-2 text-telivity-deep-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue disabled:opacity-40">
+
reorder.mutate({ from: index, to: index + 1 })} disabled={index === installments.length - 1 || reorder.isPending} className="rounded-lg border border-slate-300 p-2 text-telivity-deep-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue disabled:opacity-40">
+
setEditing(installment)} className="rounded-lg border border-slate-300 p-2 text-telivity-deep-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue">
+
remove.mutate(installment.id)} disabled={!canRemove || remove.isPending} className="inline-flex items-center gap-2 rounded-lg border border-slate-300 p-2 text-telivity-orange focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-orange disabled:opacity-40"> {isPartial ? {t('bookingRequests.payments.removeRemainingAmount', { amount: remainingPaid! })} : null}
+
+ ) : null}
+
+ {allocating?.id === installment.id ? setAllocating(null)} /> : null}
+
+ );
+ })}
+ {!installments.length && !installmentsQuery.isLoading ?
{t('bookingRequests.payments.noInstallments')}
: null}
+ {reorder.isError ?
{t('bookingRequests.payments.reorderError')}
: null}
+
+
+
+
+ {t('bookingRequests.payments.movements')}
+
+ {payments.movements.map((payment) => {
+ const remaining = availableResolutionByPayment.get(payment.id) ?? 0;
+ return (
+
+
+
+
+
{formatMoney(payment.amount, payment.currencyCode)}
+
+
+
{provenance(payment)}
+ {!payment.originalPaymentId ? (
+
+ {t('bookingRequests.payments.allocationAvailability', {
+ available: formatMoney(payment.availableToAllocate, payment.currencyCode),
+ allocated: formatMoney(payment.allocatedAmount, payment.currencyCode),
+ })}
+
+ ) : null}
+ {payment.notes ?
{payment.notes}
: null}
+
+ {canWrite && !payment.originalPaymentId && remaining > 0 && request.status !== 'denied' ? (
+
+ setPaymentAction({ action: payment.source === 'saved_card' ? 'refund' : 'external_return', payment, amount: String(remaining) })} className="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold text-telivity-deep-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-deep-blue">
+ {payment.source === 'saved_card' ? t('bookingRequests.paymentActions.refund.action') : t('bookingRequests.paymentActions.external_return.action')}
+
+ {request.status === 'pending' ? setPaymentAction({ action: 'retain', payment, amount: String(remaining) })} className="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold text-telivity-orange focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-telivity-orange">{t('bookingRequests.paymentActions.retain.open')} : null}
+
+ ) : null}
+
+
+ );
+ })}
+ {!payments.movements.length && !paymentsQuery.isLoading ?
{t('bookingRequests.payments.noMovements')}
: null}
+
+
+
+ {paymentAction ? (
+
setPaymentAction(null)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/apps/dashboard/src/components/booking-requests/bookingRequestPaymentsQuery.ts b/apps/dashboard/src/components/booking-requests/bookingRequestPaymentsQuery.ts
new file mode 100644
index 00000000..21ee40df
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/bookingRequestPaymentsQuery.ts
@@ -0,0 +1,29 @@
+import { api } from '../../lib/api';
+import type { BookingRequestPaymentsResponse } from './types';
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value != null;
+}
+
+function isPaymentsResponse(value: unknown): value is BookingRequestPaymentsResponse {
+ return isRecord(value)
+ && Array.isArray(value.movements)
+ && Array.isArray(value.allocations)
+ && Array.isArray(value.resolutions);
+}
+
+export async function fetchBookingRequestPayments(
+ requestId: string,
+ propertyId: string,
+): Promise {
+ const response = await api.get(`/v1/booking-requests/${requestId}/payments`, {
+ params: { propertyId },
+ });
+ const payload = isRecord(response.data) && 'data' in response.data
+ ? response.data.data
+ : response.data;
+ if (!isPaymentsResponse(payload)) {
+ throw new Error('Booking request payment response is incomplete');
+ }
+ return payload;
+}
diff --git a/apps/dashboard/src/components/booking-requests/moneyInput.test.ts b/apps/dashboard/src/components/booking-requests/moneyInput.test.ts
new file mode 100644
index 00000000..7b2d7336
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/moneyInput.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { validateMoneyInput, validatePercentageInput } from './moneyInput';
+
+describe('booking request money input', () => {
+ it('canonicalizes by padding only and never rounds staff input', () => {
+ expect(validateMoneyInput('75', 'EUR')).toEqual({ canonical: '75.00', error: null });
+ expect(validateMoneyInput('75.5', 'EUR')).toEqual({ canonical: '75.50', error: null });
+ expect(validateMoneyInput('75.555', 'EUR')).toEqual({ canonical: null, error: 'precision' });
+ });
+
+ it('enforces zero-decimal currencies and rejects unsupported three-decimal ledgers', () => {
+ expect(validateMoneyInput('120', 'JPY')).toEqual({ canonical: '120', error: null });
+ expect(validateMoneyInput('120.0', 'JPY')).toEqual({ canonical: null, error: 'precision' });
+ expect(validateMoneyInput('1.000', 'KWD')).toEqual({ canonical: null, error: 'unsupportedCurrency' });
+ });
+
+ it('distinguishes nonnumeric, noncanonical, and nonpositive values', () => {
+ expect(validateMoneyInput('abc', 'EUR').error).toBe('format');
+ expect(validateMoneyInput('01.00', 'EUR').error).toBe('format');
+ expect(validateMoneyInput('0.00', 'EUR').error).toBe('positive');
+ expect(validateMoneyInput('', 'EUR').error).toBe('required');
+ });
+
+ it('validates percentages without floating-point rounding', () => {
+ expect(validatePercentageInput('30.5')).toEqual({ canonical: '30.50', error: null });
+ expect(validatePercentageInput('30.555').error).toBe('precision');
+ expect(validatePercentageInput('100.01').error).toBe('maximum');
+ });
+});
diff --git a/apps/dashboard/src/components/booking-requests/moneyInput.ts b/apps/dashboard/src/components/booking-requests/moneyInput.ts
new file mode 100644
index 00000000..6f02245f
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/moneyInput.ts
@@ -0,0 +1,67 @@
+export type MoneyInputError =
+ | 'required'
+ | 'format'
+ | 'positive'
+ | 'precision'
+ | 'unsupportedCurrency'
+ | 'maximum';
+
+export interface ValidatedDecimalInput {
+ canonical: string | null;
+ error: MoneyInputError | null;
+}
+
+const DECIMAL_INPUT = /^(0|[1-9]\d*)(?:\.(\d+))?$/;
+
+function currencyExponent(currencyCode: string): number | null {
+ try {
+ return new Intl.NumberFormat('en', {
+ style: 'currency',
+ currency: currencyCode.toUpperCase(),
+ }).resolvedOptions().maximumFractionDigits ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function validateDecimal(
+ value: string,
+ maximumFractionDigits: number,
+ maximum?: number,
+): ValidatedDecimalInput {
+ if (value === '') return { canonical: null, error: 'required' };
+ const match = DECIMAL_INPUT.exec(value);
+ if (!match) return { canonical: null, error: 'format' };
+ const fraction = match[2] ?? '';
+ if (fraction.length > maximumFractionDigits) {
+ return { canonical: null, error: 'precision' };
+ }
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return { canonical: null, error: 'format' };
+ if (numeric <= 0) return { canonical: null, error: 'positive' };
+ if (maximum != null && numeric > maximum) {
+ return { canonical: null, error: 'maximum' };
+ }
+ const [whole] = value.split('.');
+ return {
+ canonical: maximumFractionDigits === 0
+ ? whole!
+ : `${whole}.${fraction.padEnd(maximumFractionDigits, '0')}`,
+ error: null,
+ };
+}
+
+export function validateMoneyInput(
+ value: string,
+ currencyCode: string,
+): ValidatedDecimalInput {
+ const exponent = currencyExponent(currencyCode);
+ if (exponent == null || exponent > 2) {
+ return { canonical: null, error: 'unsupportedCurrency' };
+ }
+ return validateDecimal(value, exponent);
+}
+
+export function validatePercentageInput(value: string): ValidatedDecimalInput {
+ return validateDecimal(value, 2, 100);
+}
diff --git a/apps/dashboard/src/components/booking-requests/queryKeys.ts b/apps/dashboard/src/components/booking-requests/queryKeys.ts
new file mode 100644
index 00000000..0ce5d294
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/queryKeys.ts
@@ -0,0 +1,54 @@
+export const bookingRequestKeys = {
+ root: (propertyId: string) => ['booking-requests', propertyId] as const,
+ list: (propertyId: string, filters: Record) =>
+ ['booking-requests', propertyId, 'list', filters] as const,
+ detail: (propertyId: string, requestId: string) =>
+ ['booking-requests', propertyId, 'detail', requestId] as const,
+ acceptancePreview: (propertyId: string, requestId: string) =>
+ ['booking-requests', propertyId, 'acceptance-preview', requestId] as const,
+ stayAmendmentPreview: (
+ propertyId: string,
+ requestId: string,
+ arrivalDate: string,
+ departureDate: string,
+ ) => [
+ 'booking-requests', propertyId, 'stay-amendment-preview',
+ requestId, arrivalDate, departureDate,
+ ] as const,
+ paymentsRoot: (propertyId: string) => ['booking-request-payments', propertyId] as const,
+ payments: (propertyId: string, requestId: string) =>
+ ['booking-request-payments', propertyId, requestId] as const,
+ installmentsRoot: (propertyId: string) => ['booking-request-installments', propertyId] as const,
+ installments: (propertyId: string, requestId: string) =>
+ ['booking-request-installments', propertyId, requestId] as const,
+ messagesRoot: (propertyId: string) => ['booking-request-messages', propertyId] as const,
+ messages: (propertyId: string, requestId: string) =>
+ ['booking-request-messages', propertyId, requestId] as const,
+ auditRoot: (propertyId: string) => ['booking-request-audit', propertyId] as const,
+ audit: (propertyId: string, requestId: string) =>
+ ['booking-request-audit', propertyId, requestId] as const,
+ foliosRoot: (propertyId: string) => ['booking-request-folios', propertyId] as const,
+ folioWorkspace: (
+ propertyId: string,
+ requestId: string,
+ reservationId: string | null,
+ ) => [
+ 'booking-request-folios',
+ propertyId,
+ requestId,
+ reservationId ?? 'pre-acceptance',
+ ] as const,
+ folio: (
+ propertyId: string,
+ requestId: string,
+ reservationId: string | null,
+ folioId: string,
+ ) => [
+ 'booking-request-folios',
+ propertyId,
+ requestId,
+ reservationId ?? 'pre-acceptance',
+ folioId,
+ ] as const,
+ genericFoliosRoot: (propertyId: string) => ['folios', propertyId] as const,
+};
diff --git a/apps/dashboard/src/components/booking-requests/types.ts b/apps/dashboard/src/components/booking-requests/types.ts
new file mode 100644
index 00000000..f1a4d5f3
--- /dev/null
+++ b/apps/dashboard/src/components/booking-requests/types.ts
@@ -0,0 +1,247 @@
+export type BookingRequestStatus = 'pending' | 'accepted' | 'denied';
+export type BookingRequestPriceSource = 'submitted' | 'current' | 'custom' | null;
+
+export interface QuoteSnapshot {
+ currencyCode?: string;
+ grandTotal?: string;
+ roomTotal?: string;
+ taxTotal?: string;
+ servicesTotal?: string;
+ lineItems?: Array>;
+ [key: string]: unknown;
+}
+
+export interface BookingFormQuestionSnapshot {
+ id: string;
+ label: string;
+ type: string;
+ order: number;
+ isActive: boolean;
+ isRequired: boolean;
+ options?: string[];
+}
+
+export interface BookingRequestListItem {
+ id: string;
+ propertyId: string;
+ status: BookingRequestStatus;
+ arrivalDate: string;
+ departureDate: string;
+ roomTypeId: string;
+ ratePlanId: string;
+ adults: number;
+ children: number;
+ guestFirstName: string;
+ guestLastName: string;
+ guestEmail: string;
+ hasCard: boolean;
+ submittedTotal: string;
+ currencyCode: string;
+ acceptedPriceSource: BookingRequestPriceSource;
+ acceptedTotal: string | null;
+ acceptedReservationId: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface BookingRequestDetail extends Omit {
+ guestPhone: string | null;
+ specialRequests: string | null;
+ serviceIds: string[];
+ formSnapshot: BookingFormQuestionSnapshot[];
+ applicationAnswers: Record;
+ submittedQuoteSnapshot: QuoteSnapshot;
+ currentQuoteSnapshot: QuoteSnapshot | null;
+ card: { brand: string | null; lastFour: string | null } | null;
+ customPriceReason: string | null;
+ acceptedFolioId: string | null;
+ decidedBy: string | null;
+ decidedAt: string | null;
+ denialReason: string | null;
+ operationalReservation: {
+ id: string;
+ arrivalDate: string;
+ departureDate: string;
+ totalAmount: string;
+ currencyCode: string;
+ roomTypeId: string;
+ ratePlanId: string;
+ status: string;
+ updatedAt: string;
+ } | null;
+}
+
+export interface BookingRequestAcceptancePreview {
+ requestId: string;
+ submittedTotal: string;
+ currentTotal: string;
+ currencyCode: string;
+ previewVersion: 1;
+ previewToken: string;
+}
+
+export type StayAmendmentPriceSource = 'prior' | 'current' | 'custom';
+
+export interface BookingRequestStayAmendmentPreview {
+ requestId: string;
+ reservationId: string;
+ previousArrivalDate: string;
+ previousDepartureDate: string;
+ previousTotal: string;
+ arrivalDate: string;
+ departureDate: string;
+ priorTotal: string;
+ currentTotal: string;
+ currencyCode: string;
+ previewVersion: 1;
+ previewToken: string;
+}
+
+export interface BookingRequestAuditHistoryItem {
+ source: 'audit_log';
+ id: string;
+ action: string;
+ actorDisplay: string;
+ occurredAt: string;
+ summary: string;
+ details: Record;
+}
+
+export interface BookingRequestInstallment {
+ id: string;
+ propertyId: string;
+ bookingRequestId: string;
+ label: string;
+ sortOrder: number;
+ fixedAmount: string | null;
+ percentage: string | null;
+ resolvedAmount: string;
+ dueMilestone: 'date' | 'arrival' | 'checkout' | 'manual';
+ dueDate: string | null;
+ allocatedAmount: string;
+ status: 'unpaid' | 'partial' | 'paid';
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export interface BookingRequestPayment {
+ id: string;
+ propertyId: string;
+ bookingRequestId: string;
+ folioId: string | null;
+ method: string;
+ status: string;
+ amount: string;
+ netCapturedAmount: string;
+ allocatedAmount: string;
+ reservedResolutionAmount: string;
+ availableToAllocate: string;
+ availableToResolve: string;
+ unresolvedAmount: string;
+ returnedAmount: string;
+ retainedAmount: string;
+ /** @deprecated Use availableToAllocate. */
+ availableAmount: string;
+ currencyCode: string;
+ source: 'saved_card' | 'external';
+ gatewayProvider: string | null;
+ reference: string | null;
+ cardLastFour: string | null;
+ cardBrand: string | null;
+ originalPaymentId: string | null;
+ notes: string | null;
+ processedAt: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface BookingRequestPaymentAllocation {
+ id: string;
+ propertyId: string;
+ bookingRequestId: string;
+ paymentId: string;
+ installmentId: string;
+ amount: string;
+ createdAt: string;
+}
+
+export interface BookingRequestPaymentResolution {
+ id: string;
+ propertyId: string;
+ bookingRequestId: string;
+ paymentId: string;
+ type: 'refund' | 'external_return' | 'retained';
+ status: string;
+ amount: string;
+ movementId: string | null;
+ reason: string | null;
+ resolvedBy: string | null;
+ resolvedAt: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface BookingRequestPaymentsResponse {
+ movements: BookingRequestPayment[];
+ allocations: BookingRequestPaymentAllocation[];
+ resolutions: BookingRequestPaymentResolution[];
+}
+
+export interface BookingRequestEmailDelivery {
+ id: string;
+ kind: 'receipt' | 'accepted' | 'denied' | 'payment' | 'refund' | 'failure';
+ status: 'pending' | 'processing' | 'sent' | 'failed';
+ subject: string;
+ bodyText: string;
+ errorMessage: string | null;
+ attempts: number;
+ nextAttemptAt: string | null;
+ lastAttemptAt: string | null;
+ sentAt: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface FolioSummary {
+ id: string;
+ folioNumber: string;
+ status: string;
+ totalCharges: string;
+ totalPayments: string;
+ balance: string;
+ currencyCode: string;
+}
+
+export interface UnresolvedPayment {
+ payment: BookingRequestPayment;
+ amount: number;
+}
+
+export function quoteTotal(snapshot: QuoteSnapshot | null | undefined): string | null {
+ return typeof snapshot?.grandTotal === 'string' ? snapshot.grandTotal : null;
+}
+
+export function unresolvedPayments(
+ response: BookingRequestPaymentsResponse | undefined,
+): UnresolvedPayment[] {
+ if (!response) return [];
+ const unresolved: UnresolvedPayment[] = [];
+ for (const payment of response.movements) {
+ if (payment.originalPaymentId) continue;
+ const amount = Number(payment.unresolvedAmount);
+ if (amount > 0.000001) unresolved.push({ payment, amount });
+ }
+ return unresolved;
+}
+
+export function apiErrorMessage(error: unknown, fallback: string): string {
+ if (error && typeof error === 'object') {
+ const response = (error as { response?: { data?: { message?: unknown } } }).response;
+ const message = response?.data?.message;
+ if (Array.isArray(message)) return message.map(String).join(', ');
+ if (typeof message === 'string' && message.trim()) return message;
+ const direct = (error as { message?: unknown }).message;
+ if (typeof direct === 'string' && direct.trim()) return direct;
+ }
+ return fallback;
+}
diff --git a/apps/dashboard/src/components/layout/Sidebar.test.tsx b/apps/dashboard/src/components/layout/Sidebar.test.tsx
index aab2c399..19e6d72d 100644
--- a/apps/dashboard/src/components/layout/Sidebar.test.tsx
+++ b/apps/dashboard/src/components/layout/Sidebar.test.tsx
@@ -1,15 +1,54 @@
-import { describe, it, expect, vi } from 'vitest';
+import { beforeEach, describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/helpers';
import Sidebar from './Sidebar';
+const auth = vi.hoisted(() => ({
+ permissions: new Set(['reservations.read']),
+}));
+
+vi.mock('../../context/AuthContext', () => ({
+ useAuth: () => ({
+ hasRole: () => true,
+ hasPermission: (permission: string) => auth.permissions.has(permission),
+ }),
+}));
+
describe('Sidebar', () => {
+ beforeEach(() => {
+ auth.permissions = new Set([
+ 'dashboard.view',
+ 'frontdesk.access',
+ 'reservations.read',
+ 'guests.read',
+ 'rooms.read',
+ 'housekeeping.read',
+ 'folios.read',
+ 'groups.read',
+ 'commercial.read',
+ 'cashier.access',
+ 'houseaccounts.read',
+ 'accounting.view',
+ 'tax.manage',
+ 'rateplans.read',
+ 'revenue.manage',
+ 'nightaudit.run',
+ 'reports.view',
+ 'channels.manage',
+ 'settings.manage',
+ 'communications.manage',
+ 'reviews.manage',
+ 'admin.users.manage',
+ ]);
+ });
+
it('renders all navigation items', () => {
renderWithProviders( {}} />);
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByText('Front Desk')).toBeInTheDocument();
expect(screen.getByText('Reservations')).toBeInTheDocument();
+ expect(screen.getByText('Booking Requests')).toBeInTheDocument();
expect(screen.getByText('Guests')).toBeInTheDocument();
expect(screen.getByText('Rooms')).toBeInTheDocument();
expect(screen.getByText('Housekeeping')).toBeInTheDocument();
@@ -21,6 +60,19 @@ describe('Sidebar', () => {
expect(screen.getByText('Settings')).toBeInTheDocument();
});
+ it('shows booking requests only with reservations.read', () => {
+ auth.permissions.delete('reservations.read');
+ renderWithProviders( {}} />);
+ expect(screen.queryByText('Booking Requests')).not.toBeInTheDocument();
+
+ auth.permissions.add('reservations.read');
+ renderWithProviders( {}} />);
+ expect(screen.getByRole('link', { name: 'Booking Requests' })).toHaveAttribute(
+ 'href',
+ '/booking-requests',
+ );
+ });
+
it('shows HAIP branding', () => {
renderWithProviders( {}} />);
expect(screen.getByText('HAIP')).toBeInTheDocument();
diff --git a/apps/dashboard/src/components/layout/Sidebar.tsx b/apps/dashboard/src/components/layout/Sidebar.tsx
index ec1e98b5..8dc7703b 100644
--- a/apps/dashboard/src/components/layout/Sidebar.tsx
+++ b/apps/dashboard/src/components/layout/Sidebar.tsx
@@ -24,11 +24,13 @@ import {
Building2,
Calculator,
ReceiptText,
+ ClipboardList,
X,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useProperty } from '../../context/PropertyContext';
+import { isBookingRequestsUiEnabled } from '../../lib/bookingRequestsFeature';
type NavItem = {
to: string;
@@ -48,6 +50,7 @@ const NAV_SECTIONS: NavSection[] = [
items: [
{ to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard', permission: 'dashboard.view' },
{ to: '/front-desk', icon: ConciergeBell, labelKey: 'nav.frontDesk', permission: 'frontdesk.access' },
+ { to: '/booking-requests', icon: ClipboardList, labelKey: 'nav.bookingRequests', permission: 'reservations.read' },
{ to: '/reservations', icon: CalendarDays, labelKey: 'nav.reservations', permission: 'reservations.read' },
{ to: '/guests', icon: Users, labelKey: 'nav.guests', permission: 'guests.read' },
{ to: '/rooms', icon: DoorOpen, labelKey: 'nav.rooms', permission: 'rooms.read' },
@@ -93,6 +96,7 @@ function isItemVisible(
hasPermission: (key: string) => boolean,
hasRole: (...roles: string[]) => boolean,
) {
+ if (item.to === '/booking-requests' && !isBookingRequestsUiEnabled()) return false;
if (item.permission) return hasPermission(item.permission);
if (item.roles) return hasRole(...item.roles);
return true;
diff --git a/apps/dashboard/src/components/ui/Modal.test.tsx b/apps/dashboard/src/components/ui/Modal.test.tsx
index eee23ac9..7e3e8e16 100644
--- a/apps/dashboard/src/components/ui/Modal.test.tsx
+++ b/apps/dashboard/src/components/ui/Modal.test.tsx
@@ -19,17 +19,16 @@ describe('Modal', () => {
it('calls onClose when X button clicked', async () => {
const onClose = vi.fn();
render(Body );
- await userEvent.click(screen.getByRole('button'));
+ await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledOnce();
});
it('calls onClose when backdrop clicked', async () => {
const onClose = vi.fn();
- const { container } = render(
+ render(
Body ,
);
- const backdrop = container.querySelector('.bg-black\\/40');
- if (backdrop) await userEvent.click(backdrop);
+ await userEvent.click(screen.getByLabelText('Close Test'));
expect(onClose).toHaveBeenCalledOnce();
});
diff --git a/apps/dashboard/src/components/ui/Modal.tsx b/apps/dashboard/src/components/ui/Modal.tsx
index 31512cce..f931458b 100644
--- a/apps/dashboard/src/components/ui/Modal.tsx
+++ b/apps/dashboard/src/components/ui/Modal.tsx
@@ -1,4 +1,5 @@
-import { useEffect, type ReactNode } from 'react';
+import { useEffect, useId, useRef, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react';
interface ModalProps {
@@ -7,26 +8,71 @@ interface ModalProps {
title: string;
children: ReactNode;
wide?: boolean;
+ closeDisabled?: boolean;
}
-export default function Modal({ open, onClose, title, children, wide }: ModalProps) {
+export default function Modal({
+ open,
+ onClose,
+ title,
+ children,
+ wide,
+ closeDisabled = false,
+}: ModalProps) {
+ const { t } = useTranslation();
+ const titleId = useId();
+ const onCloseRef = useRef(onClose);
+ const closeDisabledRef = useRef(closeDisabled);
+
+ useEffect(() => {
+ onCloseRef.current = onClose;
+ closeDisabledRef.current = closeDisabled;
+ }, [closeDisabled, onClose]);
+
useEffect(() => {
- if (open) {
- document.body.style.overflow = 'hidden';
- return () => { document.body.style.overflow = ''; };
- }
+ if (!open) return undefined;
+ document.body.style.overflow = 'hidden';
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && !closeDisabledRef.current) {
+ event.preventDefault();
+ onCloseRef.current();
+ }
+ };
+ document.addEventListener('keydown', onKeyDown);
+ return () => {
+ document.body.style.overflow = '';
+ document.removeEventListener('keydown', onKeyDown);
+ };
}, [open]);
if (!open) return null;
return (
-
-
+
+
-
{title}
-
-
+ {title}
+
+
{children}
diff --git a/apps/dashboard/src/hooks/useRealtimeInvalidation.test.ts b/apps/dashboard/src/hooks/useRealtimeInvalidation.test.ts
new file mode 100644
index 00000000..ba8172ff
--- /dev/null
+++ b/apps/dashboard/src/hooks/useRealtimeInvalidation.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+import { realtimeQueryKeys } from './useRealtimeInvalidation';
+
+describe('booking request realtime invalidation', () => {
+ const timestamp = '2026-08-25T09:00:00.000Z';
+
+ it('maps the real room-scoped socket envelope to the active property', () => {
+ expect(realtimeQueryKeys('property-1', {
+ event: 'booking_request.accepted',
+ data: { requestId: 'request-1', reservationId: 'reservation-1', folioId: 'folio-1' },
+ timestamp,
+ })).toEqual(expect.arrayContaining([
+ ['booking-requests', 'property-1'],
+ ['booking-requests', 'property-1', 'detail', 'request-1'],
+ ['reservations', 'property-1'],
+ ['folios', 'property-1'],
+ ['booking-request-messages', 'property-1', 'request-1'],
+ ['booking-request-audit', 'property-1', 'request-1'],
+ ]));
+ });
+
+ it.each([
+ ['payment.refunded', { bookingRequestId: 'request-1', folioId: 'folio-1' }],
+ ['reservation.modified', { reservationId: 'reservation-1' }],
+ ['folio.settled', { folioId: 'folio-1' }],
+ ['audit.completed', {}],
+ ['guest.communication_sent', { bookingRequestId: 'request-1' }],
+ ])('refreshes every request workspace for the canonical %s event', (event, data) => {
+ expect(realtimeQueryKeys('property-1', { event, data, timestamp })).toContainEqual([
+ 'booking-requests',
+ 'property-1',
+ ]);
+ });
+
+ it('invalidates every property request prefix for a reservation-only payload', () => {
+ const keys = realtimeQueryKeys('property-1', {
+ event: 'reservation.modified',
+ data: { reservationId: 'reservation-1' },
+ timestamp,
+ });
+
+ expect(keys).toEqual(expect.arrayContaining([
+ ['booking-requests', 'property-1'],
+ ['booking-request-payments', 'property-1'],
+ ['booking-request-installments', 'property-1'],
+ ['booking-request-messages', 'property-1'],
+ ['booking-request-audit', 'property-1'],
+ ['booking-request-folios', 'property-1'],
+ ]));
+ expect(keys.flat()).not.toContain('property-2');
+ });
+
+ it('invalidates request money/messages/audit for canonical payment events only', () => {
+ const keys = realtimeQueryKeys('property-1', {
+ event: 'payment.refunded',
+ data: { bookingRequestId: 'request-1', folioId: 'folio-1' },
+ timestamp,
+ });
+ expect(keys).toEqual(expect.arrayContaining([
+ ['payments', 'property-1'],
+ ['folios', 'property-1'],
+ ['booking-request-payments', 'property-1', 'request-1'],
+ ['booking-request-messages', 'property-1', 'request-1'],
+ ['booking-request-audit', 'property-1', 'request-1'],
+ ]));
+ expect(realtimeQueryKeys('property-1', {
+ event: 'payment.provider_secret_changed',
+ data: {},
+ timestamp,
+ })).toEqual([]);
+ });
+
+ it('never trusts a property carried in event data and includes legacy root prefixes', () => {
+ expect(realtimeQueryKeys(null, {
+ event: 'booking_request.created',
+ data: { requestId: 'request-1' },
+ timestamp,
+ })).toEqual([]);
+ const keys = realtimeQueryKeys('property-2', {
+ event: 'reservation.modified',
+ data: {
+ bookingRequestId: 'request-1',
+ propertyId: 'property-1',
+ reservationId: 'reservation-1',
+ },
+ timestamp,
+ });
+ expect(keys).toEqual(expect.arrayContaining([
+ ['reservations'],
+ ['rooms'],
+ ['reports'],
+ ['reservations', 'property-2'],
+ ]));
+ expect(keys.some((key) => key.includes('property-1'))).toBe(false);
+ });
+
+ it.each([
+ ['payment.refunded', ['payments'], ['folios']],
+ ['reservation.modified', ['reservations'], ['rooms']],
+ ['folio.settled', ['folios'], ['payments']],
+ ['room.status_changed', ['rooms'], ['housekeeping']],
+ ])('keeps object- and subtype-shaped query keys reachable for %s', (event, first, second) => {
+ const keys = realtimeQueryKeys('property-1', { event, data: {}, timestamp });
+ expect(keys).toContainEqual(first);
+ expect(keys).toContainEqual(second);
+ });
+});
diff --git a/apps/dashboard/src/hooks/useRealtimeInvalidation.ts b/apps/dashboard/src/hooks/useRealtimeInvalidation.ts
index f4f4d040..a621b6eb 100644
--- a/apps/dashboard/src/hooks/useRealtimeInvalidation.ts
+++ b/apps/dashboard/src/hooks/useRealtimeInvalidation.ts
@@ -1,35 +1,160 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
+import { WEBHOOK_EVENTS } from '@telivityhaip/shared';
import { getSocket } from '../lib/socket';
+import { useProperty } from '../context/PropertyContext';
+import { bookingRequestKeys } from '../components/booking-requests/queryKeys';
-const EVENT_KEY_MAP: Record
= {
- 'reservation.': [['reservations'], ['rooms'], ['reports']],
- 'room.': [['rooms'], ['housekeeping']],
- 'housekeeping.': [['housekeeping'], ['rooms']],
- 'folio.': [['folios'], ['payments']],
- 'payment.': [['payments'], ['folios']],
- 'audit.': [['audit'], ['reports']],
- 'channel.': [['channels']],
- 'agent.': [['agents'], ['agent-decisions'], ['agent-performance']],
- 'guest.': [['agent-decisions'], ['reviews']],
- 'connect.': [['connect']],
-};
+interface PmsEventPayload {
+ event: string;
+ data?: Record;
+ timestamp: string;
+}
+
+function stringValue(value: unknown): string | undefined {
+ return typeof value === 'string' && value ? value : undefined;
+}
+
+function uniqueKeys(keys: string[][]): string[][] {
+ const seen = new Set();
+ return keys.filter((key) => {
+ const identity = JSON.stringify(key);
+ if (seen.has(identity)) return false;
+ seen.add(identity);
+ return true;
+ });
+}
+
+/**
+ * Socket events are delivered to an authenticated property room. The envelope
+ * intentionally does not repeat that property ID, so the active room — never
+ * untrusted event data — supplies the query scope.
+ */
+export function realtimeQueryKeys(
+ propertyId: string | null,
+ payload: PmsEventPayload,
+): string[][] {
+ if (
+ !propertyId
+ || !Object.prototype.hasOwnProperty.call(WEBHOOK_EVENTS, payload.event)
+ ) return [];
+
+ const data = payload.data ?? {};
+ const requestId = stringValue(data.bookingRequestId)
+ ?? stringValue(data.requestId);
+ const reservationId = stringValue(data.reservationId);
+ const folioId = stringValue(data.folioId);
+
+ const requestKeys = (): string[][] => [
+ [...bookingRequestKeys.root(propertyId)],
+ [...bookingRequestKeys.paymentsRoot(propertyId)],
+ [...bookingRequestKeys.installmentsRoot(propertyId)],
+ [...bookingRequestKeys.messagesRoot(propertyId)],
+ [...bookingRequestKeys.auditRoot(propertyId)],
+ [...bookingRequestKeys.foliosRoot(propertyId)],
+ ...(requestId ? [
+ [...bookingRequestKeys.detail(propertyId, requestId)],
+ [...bookingRequestKeys.payments(propertyId, requestId)],
+ [...bookingRequestKeys.installments(propertyId, requestId)],
+ [...bookingRequestKeys.messages(propertyId, requestId)],
+ [...bookingRequestKeys.audit(propertyId, requestId)],
+ ] : []),
+ ...(requestId && folioId ? [[
+ ...bookingRequestKeys.folio(
+ propertyId,
+ requestId,
+ reservationId ?? null,
+ folioId,
+ ),
+ ]] : []),
+ ];
+
+ if (payload.event.startsWith('booking_request.')) {
+ return uniqueKeys([
+ ...requestKeys(),
+ ...(reservationId ? [['reservations'], ['reservations', propertyId]] : []),
+ ...(folioId ? [['folios'], ['folios', propertyId]] : []),
+ ...(folioId ? [['payments'], ['payments', propertyId]] : []),
+ ]);
+ }
+ if (payload.event.startsWith('payment.')) {
+ return uniqueKeys([
+ ['payments'],
+ ['payments', propertyId],
+ ['folios'],
+ ['folios', propertyId],
+ ...requestKeys(),
+ ]);
+ }
+ if (payload.event.startsWith('reservation.')) {
+ return uniqueKeys([
+ ['reservations'],
+ ['reservations', propertyId],
+ ['rooms'],
+ ['rooms', propertyId],
+ ['reports'],
+ ['reports', propertyId],
+ ...requestKeys(),
+ ]);
+ }
+ if (payload.event.startsWith('folio.')) {
+ return uniqueKeys([
+ ['folios'],
+ ['folios', propertyId],
+ ['payments'],
+ ['payments', propertyId],
+ ...requestKeys(),
+ ]);
+ }
+ if (payload.event.startsWith('audit.')) {
+ return uniqueKeys([
+ ['audit'],
+ ['audit', propertyId],
+ ['reports'],
+ ['reports', propertyId],
+ ...requestKeys(),
+ ]);
+ }
+ if (payload.event === 'guest.communication_sent') {
+ return uniqueKeys([
+ ['communications'],
+ ['communications', propertyId],
+ ...requestKeys(),
+ ]);
+ }
+ if (payload.event.startsWith('room.')) {
+ return [['rooms'], ['rooms', propertyId], ['housekeeping'], ['housekeeping', propertyId]];
+ }
+ if (payload.event.startsWith('housekeeping.')) {
+ return [['housekeeping'], ['housekeeping', propertyId], ['rooms'], ['rooms', propertyId]];
+ }
+ if (payload.event.startsWith('channel.')) return [['channels'], ['channels', propertyId]];
+ if (payload.event.startsWith('agent.')) {
+ return [
+ ['agents'], ['agents', propertyId],
+ ['agent-decisions'], ['agent-decisions', propertyId],
+ ['agent-performance'], ['agent-performance', propertyId],
+ ];
+ }
+ if (payload.event.startsWith('guest.')) {
+ return [['agent-decisions'], ['agent-decisions', propertyId], ['reviews'], ['reviews', propertyId]];
+ }
+ if (payload.event.startsWith('connect.')) return [['connect'], ['connect', propertyId]];
+ return [];
+}
export function useRealtimeInvalidation() {
const queryClient = useQueryClient();
+ const { propertyId, isPortfolioMode } = useProperty();
+ const activePropertyId = isPortfolioMode ? null : propertyId;
useEffect(() => {
const socket = getSocket();
- function handleEvent(payload: { event: string }) {
+ function handleEvent(payload: PmsEventPayload) {
if (!payload?.event) return;
-
- for (const [prefix, keys] of Object.entries(EVENT_KEY_MAP)) {
- if (payload.event.startsWith(prefix)) {
- for (const key of keys) {
- queryClient.invalidateQueries({ queryKey: key });
- }
- }
+ for (const key of realtimeQueryKeys(activePropertyId, payload)) {
+ queryClient.invalidateQueries({ queryKey: key });
}
}
@@ -37,5 +162,5 @@ export function useRealtimeInvalidation() {
return () => {
socket.off('pmsEvent', handleEvent);
};
- }, [queryClient]);
+ }, [activePropertyId, queryClient]);
}
diff --git a/apps/dashboard/src/lib/bookingRequestsFeature.ts b/apps/dashboard/src/lib/bookingRequestsFeature.ts
new file mode 100644
index 00000000..2366e315
--- /dev/null
+++ b/apps/dashboard/src/lib/bookingRequestsFeature.ts
@@ -0,0 +1,4 @@
+/** Mirrors server HAIP_BOOKING_REQUESTS for dashboard UI gating. */
+export function isBookingRequestsUiEnabled(): boolean {
+ return import.meta.env.VITE_HAIP_BOOKING_REQUESTS === 'true';
+}
diff --git a/apps/dashboard/src/locales/de.json b/apps/dashboard/src/locales/de.json
index 06a61977..c9a7894c 100644
--- a/apps/dashboard/src/locales/de.json
+++ b/apps/dashboard/src/locales/de.json
@@ -30,6 +30,152 @@
"transfer": "Übertragen",
"transferBalance": "Saldo übertragen"
},
+ "bookingEngine": {
+ "accentColor": "Akzentfarbe",
+ "active": "aktiv",
+ "autoConfirm": "Bezahlte Buchungen automatisch bestätigen",
+ "branding": "Markendarstellung",
+ "copied": "Kopiert",
+ "copy": "Kopieren",
+ "copyKeyWarning": "Kopieren Sie diesen Schlüssel jetzt – er kann später nicht erneut angezeigt werden.",
+ "created": "Erstellt",
+ "depositPolicy": "Anzahlungsrichtlinie",
+ "depositTypes": {
+ "first_night": "Erste Nacht",
+ "full": "Gesamtbetrag",
+ "none": "Keine Anzahlung",
+ "percentage": "Prozentsatz"
+ },
+ "description": "Ermöglichen Sie Gästen provisionsfreie Direktbuchungen über Ihre Website.",
+ "disabled": "Deaktiviert",
+ "dismiss": "Schließen",
+ "displayName": "Anzeigename",
+ "displayNamePlaceholder": "Wird Gästen auf der Buchungsseite angezeigt",
+ "enabled": "Aktiviert",
+ "generateKey": "Schlüssel erstellen",
+ "key": "Schlüssel",
+ "label": "Bezeichnung",
+ "logo": "Logo",
+ "noKeysYet": "Noch keine veröffentlichbaren Schlüssel",
+ "noRatePlans": "Keine Ratenpläne",
+ "noRoomTypes": "Keine Zimmertypen",
+ "percentageLabel": "Prozentsatz",
+ "primaryColor": "Primärfarbe",
+ "promptLabel": "Bezeichnung für diesen veröffentlichbaren Schlüssel (z. B. „Marketing-Website“)",
+ "publishableKeys": "Veröffentlichbare Schlüssel",
+ "ratePlans": "Ratenpläne",
+ "refundableDeposit": "Erstattungsfähige Anzahlung",
+ "revoke": "Widerrufen",
+ "revoked": "widerrufen",
+ "roomTypes": "Zimmertypen",
+ "sellableInventory": "Verkaufbares Inventar",
+ "sellableInventoryDescription": "Nur ausgewählte Zimmertypen und Ratenpläne sind öffentlich buchbar.",
+ "title": "Direktbuchungsmodul",
+ "toasts": {
+ "keyGenerated": "Veröffentlichbarer Schlüssel erstellt",
+ "keyGenerationFailed": "Schlüssel konnte nicht erstellt werden",
+ "keyRevoked": "Schlüssel widerrufen",
+ "keyRevocationFailed": "Schlüssel konnte nicht widerrufen werden",
+ "settingsSaved": "Einstellungen des Buchungsmoduls gespeichert",
+ "settingsSaveFailed": "Einstellungen konnten nicht gespeichert werden"
+ },
+ "type": "Typ",
+ "requestSettings": {
+ "autoConfirmDescription": "Die automatische Bestätigung gilt nur für Sofortbuchungen; Anfragen erfordern immer eine Entscheidung des Personals.",
+ "bookingMode": "Buchungsmodus",
+ "backgroundLoadError": "Die neuesten Einstellungen konnten nicht geprüft werden. Die geladenen Einstellungen bleiben verfügbar.",
+ "cardCollection": "Kartenerfassung",
+ "cardDescriptions": {
+ "disabled": "Gäste senden ihre Anfrage ohne Karte.",
+ "optional": "Gäste entscheiden, ob sie eine Karte für vom Personal ausgelöste Zahlungen speichern.",
+ "required": "Gäste müssen vor dem Senden eine Karte speichern. Es erfolgt keine automatische Belastung."
+ },
+ "cardPolicies": {
+ "disabled": "Deaktiviert",
+ "optional": "Optional",
+ "required": "Erforderlich"
+ },
+ "conflictDescription": "Diese Einstellungen wurden geändert, seit Sie diese Seite geöffnet haben. Ihr Entwurf bleibt erhalten. Laden Sie die neuesten Einstellungen, bevor Sie weiterarbeiten.",
+ "conflictTitle": "Einstellungskonflikt",
+ "description": "Wählen Sie zwischen Sofortbuchung und einer Anfrage, die Ihr Team prüft.",
+ "engineToggle": "Direktbuchungsmodul aktivieren",
+ "invalidQuestions": "Korrigieren Sie das Gästeformular, bevor Sie diese Einstellungen speichern.",
+ "loadError": "Die Einstellungen des Buchungsmoduls konnten nicht geladen werden.",
+ "loading": "Einstellungen des Buchungsmoduls werden geladen",
+ "modeDescriptions": {
+ "instant": "Ein erfolgreicher Abschluss folgt dem bestehenden Sofortbuchungsablauf.",
+ "request": "Beim Senden entsteht eine ausstehende Anfrage ohne bestätigte Reservierung."
+ },
+ "modes": {
+ "instant": "Sofortbuchung",
+ "request": "Buchungsanfrage"
+ },
+ "requiredCardWarning": "Fügen Sie einen veröffentlichbaren Stripe-Schlüssel hinzu, bevor Sie eine Karte verlangen.",
+ "unsupportedCardWarning": "Der konfigurierte Zahlungsanbieter unterstützt keine gespeicherten Karten. Deaktivieren Sie die Kartenerfassung oder wählen Sie Stripe.",
+ "reloadLatest": "Neueste Einstellungen laden",
+ "reset": "Änderungen zurücksetzen",
+ "retry": "Erneut versuchen",
+ "save": "Änderungen speichern",
+ "saveError": "Die Einstellungen des Buchungsmoduls konnten nicht gespeichert werden.",
+ "saving": "Änderungen werden gespeichert",
+ "stripeKey": "Veröffentlichbarer Stripe-Schlüssel",
+ "stripeKeyDescription": "Verwenden Sie den veröffentlichbaren Schlüssel dieser Unterkunft. Geheime Stripe-Schlüssel bleiben auf dem Server.",
+ "stripeKeyPlaceholder": "pk_live_…",
+ "title": "Ablauf für Buchungsanfragen",
+ "unsaved": "Ungespeicherte Änderungen",
+ "unsupportedPublishBlocked": "Verwenden Sie eine neuere Dashboard-Version, bevor Sie Änderungen an diesem Gästeformular veröffentlichen."
+ },
+ "questions": {
+ "active": "Aktiv",
+ "activeQuestion": "Aktive Frage",
+ "add": "Frage hinzufügen",
+ "addOption": "Option hinzufügen",
+ "addTitle": "Frage hinzufügen",
+ "cancel": "Abbrechen",
+ "cancelEditor": "Frageneditor schließen",
+ "count": "Fragen: {{count}} / {{max}}",
+ "description": "Erstellen Sie das geordnete Formular, das Gäste vor einer Buchungsanfrage ausfüllen.",
+ "disable": "{{label}} deaktivieren",
+ "duplicateIds": "Fragen-IDs müssen eindeutig sein, bevor dieses Formular bearbeitet werden kann.",
+ "edit": "{{label}} bearbeiten",
+ "editTitle": "Frage bearbeiten",
+ "emptyDescription": "Fragen Sie nur Informationen ab, die Ihr Team zur Prüfung benötigt.",
+ "emptyTitle": "Noch keine Anwendungsfragen",
+ "enable": "{{label}} aktivieren",
+ "idError": "Eine eindeutige Fragen-ID konnte nicht erstellt werden. Versuchen Sie es erneut.",
+ "inactive": "Inaktiv",
+ "label": "Fragetext",
+ "labelRequired": "Geben Sie einen Fragetext ein.",
+ "moveDown": "{{label}} nach unten verschieben",
+ "moveOptionDown": "Option {{number}} nach unten verschieben",
+ "moveOptionUp": "Option {{number}} nach oben verschieben",
+ "moveUp": "{{label}} nach oben verschieben",
+ "optionBlank": "Optionen dürfen nicht leer sein.",
+ "optionDuplicate": "Optionen müssen eindeutig sein.",
+ "optionLabel": "Option {{number}}",
+ "optionRequired": "Fügen Sie mindestens eine Option hinzu.",
+ "optional": "Optional",
+ "options": "Antwortoptionen",
+ "remove": "{{label}} entfernen",
+ "removeOption": "Option {{number}} entfernen",
+ "required": "Erforderlich",
+ "requiredQuestion": "Pflichtfrage",
+ "save": "Frage speichern",
+ "title": "Bauplan des Gästeformulars",
+ "type": "Fragetyp",
+ "unsupportedActiveDescription": "Dieses Dashboard kann das Gästeformular nicht sicher ändern oder erneut veröffentlichen, solange diese Frage aktiv ist.",
+ "unsupportedActiveTitle": "Eine nicht unterstützte Frage ist aktiv",
+ "unsupportedType": "Nicht unterstützte Frage",
+ "types": {
+ "short_text": "Kurztext",
+ "long_text": "Langtext",
+ "single_select": "Einfachauswahl",
+ "multi_select": "Mehrfachauswahl",
+ "yes_no": "Ja / Nein",
+ "date": "Datum"
+ }
+ }
+ },
"admin": {
"actions": "Aktionen",
"addUser": "Benutzer hinzufügen",
@@ -230,6 +376,7 @@
"dashboard": "Dashboard",
"foliosBilling": "Gästekonten & Abrechnung",
"frontDesk": "Rezeption",
+ "bookingRequests": "Buchungsanfragen",
"groups": "Gruppen",
"guests": "Gäste",
"houseAccounts": "Hauskonten",
@@ -406,5 +553,364 @@
"Bad Request": "Ungültige Anfrage",
"Resource not found": "Ressource nicht gefunden",
"Action not permitted": "Aktion nicht zulässig"
+ },
+ "bookingRequests": {
+ "access": {
+ "title": "Zugang eingeschränkt",
+ "description": "Sie benötigen die Erlaubnis, Reservierungen zu lesen, bevor Sie Buchungsanfragen überprüfen können."
+ },
+ "property": {
+ "title": "Wählen Sie eine Immobilie",
+ "description": "Buchungsanfragen werden jeweils für eine Unterkunft überprüft, sodass Gäste-, Geld- und Entscheidungsunterlagen sicher im Blick bleiben."
+ },
+ "notFound": {
+ "title": "Anfrage nicht gefunden",
+ "description": "Diese Anfrage existiert in der ausgewählten Unterkunft nicht oder ist nicht mehr verfügbar."
+ },
+ "common": {
+ "cancel": "Abbrechen",
+ "loading": "Buchungsanfrage wird geladen…",
+ "status": "Status",
+ "yes": "Ja",
+ "no": "Nein",
+ "notProvided": "Nicht bereitgestellt",
+ "retry": "Erneut versuchen"
+ },
+ "actions": {
+ "accept": "Anfrage annehmen",
+ "deny": "Anfrage ablehnen",
+ "charge": "Karte belasten",
+ "record": "Zahlung erfassen"
+ },
+ "statuses": {
+ "pending": "Ausstehend",
+ "accepted": "Akzeptiert",
+ "denied": "Abgelehnt"
+ },
+ "tabs": {
+ "label": "Arbeitsbereich der Anfrage",
+ "overview": "Übersicht",
+ "payments": "Zahlungen & Plan",
+ "messages": "Nachrichten",
+ "audit": "Audit-Protokoll"
+ },
+ "queue": {
+ "title": "Buchungsanfragen",
+ "description": "Prüfen Sie die Gastanfrage, die Aufenthaltsdetails und den Zahlungsstatus, bevor Sie eine konkrete Maßnahme ergreifen.",
+ "filters": "Filter für Buchungsanfragen",
+ "guest": "Gast",
+ "card": "Karte",
+ "stay": "Aufenthalt",
+ "amount": "Angeforderter Betrag",
+ "allStatuses": "Alle Status",
+ "anyCard": "Beliebiger Kartenstatus",
+ "cardSaved": "Karte gespeichert",
+ "noCard": "Keine Karte",
+ "arrivalFrom": "Anreise von",
+ "arrivalTo": "Anreise nach",
+ "sort": "Sortieren nach",
+ "sortOptions": {
+ "newest": "Das Neueste zuerst",
+ "arrival": "Ankunftsdatum",
+ "guest": "Gastname",
+ "amountDesc": "Höchster angefragter Betrag"
+ },
+ "clear": "Zurücksetzen",
+ "loading": "Buchungsanfragen werden geladen…",
+ "loadError": "Buchungsanfragen konnten nicht geladen werden. Versuchen Sie es erneut.",
+ "empty": "Keine Buchungsanfragen entsprechen diesen Filtern.",
+ "total_one": "{{count}} Anfrage",
+ "total_other": "{{count}} Anfragen",
+ "total": "{{count}} Anfragen",
+ "previous": "Zurück",
+ "next": "Weiter"
+ },
+ "detail": {
+ "back": "Alle Buchungsanfragen",
+ "reference": "{{reference}} · Eingereicht {{date}}",
+ "decisionActions": "Entscheidung",
+ "moneyActions": "Finanzen",
+ "decisionComplete": "Entscheidung abgeschlossen",
+ "independence": "Buchungsentscheidungen und finanzielle Vorgänge sind unabhängig voneinander. Mit der Annahme einer Zahlung wird niemals eine Anfrage angenommen, und die Annahme löst niemals eine Zahlung aus."
+ },
+ "amounts": {
+ "quoted": "Angebot",
+ "submitted": "Eingereicht",
+ "current": "Aktuell",
+ "accepted": "Akzeptiert",
+ "difference": "Unterschied",
+ "captured": "Eingezogen",
+ "returned": "Zurückgezahlt",
+ "retained": "Einbehalten"
+ },
+ "overview": {
+ "stay": "Aufenthaltsanfrage",
+ "dates": "Termine",
+ "occupancy": "Belegung",
+ "occupancyValue": "{{adults}} Erwachsene · {{children}} Kinder",
+ "roomType": "Zimmertyp",
+ "ratePlan": "Tarifplan",
+ "guest": "Gast & Kontakt",
+ "email": "E-Mail",
+ "phone": "Telefon",
+ "specialRequests": "Sonderwünsche",
+ "application": "Anfrage",
+ "noQuestions": "Es wurden keine zusätzlichen Angaben zur Anfrage eingereicht.",
+ "priceComparison": "Preisvergleich",
+ "card": "Karte im Archiv",
+ "cardGeneric": "Karte",
+ "noCard": "Keine gespeicherte Karte",
+ "cardSafety": "Es werden nur die Kartenmarke und die letzten vier Ziffern angezeigt. Das Personal muss jede Belastung ausdrücklich auslösen.",
+ "decision": "Entscheidungsprotokoll",
+ "priceSource": "Preisquelle"
+ },
+ "priceSources": {
+ "submitted": "Eingereichtes Angebot",
+ "current": "Aktuelles Angebot",
+ "custom": "Benutzerdefinierte Summe"
+ },
+ "accept": {
+ "title": "Buchungsanfrage annehmen",
+ "priceChoice": "Akzeptierter Preis",
+ "submitted": "Eingereichtes Angebot",
+ "submittedDescription": "Verwenden Sie den Betrag, den der Gast beim Absenden der Anfrage gesehen hat.",
+ "current": "Aktuelles Angebot",
+ "currentDescription": "Überprüfen Sie die Verfügbarkeit erneut und berechnen Sie bei der Annahme das maßgebliche aktuelle Angebot.",
+ "custom": "Benutzerdefinierte Summe",
+ "customDescription": "Legen Sie eine explizite Summe fest und notieren Sie, warum sie abweicht.",
+ "recheckedOnAccept": "Bei Annahme erneut geprüft",
+ "enterAmount": "Geben Sie einen Betrag ein",
+ "customTotal": "Benutzerdefinierte Summe",
+ "customReason": "Grund für die benutzerdefinierte Summe",
+ "independence": "Durch Akzeptieren wird die Reservierung erstellt. Die gespeicherte Karte wird nicht belastet und es ist nicht vom Zahlungsstatus abhängig.",
+ "accepting": "Akzeptieren…",
+ "error": "Die Anfrage konnte nicht angenommen werden. Überprüfen Sie die aktuelle Verfügbarkeit und versuchen Sie es erneut."
+ },
+ "modifyStay": {
+ "action": "Aufenthalt ändern", "title": "Angenommenen Aufenthalt ändern", "activeStay": "Aktiver Aufenthalt", "proposedStay": "Geplanter Aufenthalt", "originalRequest": "Ursprüngliche Anfrage",
+ "arrivalDate": "Anreisedatum", "departureDate": "Abreisedatum", "invalidDates": "Die Abreise muss nach der Anreise liegen und beide Daten müssen gültig sein.",
+ "checking": "Gesamten Aufenthalt und aktuelles Angebot prüfen…", "previewError": "Dieser Aufenthalt kann derzeit nicht berechnet werden. Prüfen Sie die Verfügbarkeit und versuchen Sie es erneut.",
+ "priceChoice": "Preisgrundlage für den geänderten Aufenthalt", "prior": "Bisherige angenommene Grundlage", "priorDescription": "Überschneidende Daten behalten ihre angenommenen Preiszeilen; zusätzliche Nächte verwenden den nächstgelegenen angenommenen Randpreis.",
+ "current": "Aktuelles Angebot", "currentDescription": "Verwenden Sie die jetzt verfügbaren maßgeblichen Preise für den gesamten geplanten Aufenthalt.", "custom": "Benutzerdefinierte Summe", "customDescription": "Legen Sie eine positive betriebliche Summe fest und dokumentieren Sie den Grund.",
+ "awaitingQuote": "Angebot ausstehend", "enterAmount": "Betrag eingeben", "customTotal": "Benutzerdefinierte Summe", "customReason": "Grund für die benutzerdefinierte Summe",
+ "apply": "Aufenthaltsänderung anwenden", "applying": "Wird angewendet…", "commitError": "Der Aufenthalt konnte nicht geändert werden. Prüfen Sie die aktuelle Verfügbarkeit und das Angebot."
+ },
+ "deny": {
+ "title": "Buchungsanfrage ablehnen",
+ "reason": "Ablehnungsgrund",
+ "unresolved": "{{amount}} bleibt ungelöst",
+ "unresolvedDirection": "Erstatten Sie eine gespeicherte Kartenzahlung, erfassen Sie eine externe Rückgabe oder behalten Sie den Betrag mit Angabe von Gründen ein, bevor Sie ihn ablehnen.",
+ "resolveFirst": "Lösen Sie zuerst das Geld",
+ "confirm": "Ablehnung bestätigen",
+ "denying": "Wird abgelehnt…",
+ "error": "Die Anfrage konnte nicht abgelehnt werden. Überprüfen Sie den Zahlungsstatus und versuchen Sie es erneut.",
+ "moneyLoading": "Zahlungsstatus vor der Ablehnung wird geprüft…",
+ "moneyLoadError": "Der Zahlungsstatus konnte nicht geprüft werden. Die Ablehnung bleibt gesperrt.",
+ "retryMoney": "Zahlungsstatus erneut prüfen"
+ },
+ "validation": {
+ "positiveAmount": "Geben Sie einen Betrag größer als Null ein.",
+ "required": "Geben Sie einen Betrag ein.",
+ "format": "Geben Sie einen gültigen Dezimalbetrag ein.",
+ "positive": "Geben Sie einen Betrag größer als Null ein.",
+ "precision": "Verwenden Sie nur die für diese Währung zulässigen Nachkommastellen.",
+ "unsupportedCurrency": "Die Nachkommastellen dieser Währung werden vom Hauptbuch nicht unterstützt.",
+ "maximum": "Geben Sie einen Betrag innerhalb des zulässigen Höchstwerts ein."
+ },
+ "payments": {
+ "requestSummary": "Zahlungsübersicht zur Anfrage",
+ "independence": "Buchungsentscheidungen hängen nicht vom Zahlungsstatus ab.",
+ "plan": "Zahlungsplan",
+ "noAutomatic": "Es wird nichts automatisch belastet. Meilensteine sind Erinnerungen für ausdrücklich vom Personal ausgelöste Aktionen.",
+ "noAutomaticTitle": "Es wird nichts automatisch belastet.",
+ "noAutomaticDescription": "Meilensteine sind Erinnerungen an von Mitarbeitern initiierte Maßnahmen.",
+ "addInstallment": "Rate hinzufügen",
+ "editInstallment": "Rate bearbeiten",
+ "installmentLabel": "Ratenbezeichnung",
+ "amountType": "Betragstyp",
+ "fixedAmount": "Fester Betrag",
+ "percentage": "Prozentsatz",
+ "milestone": "Fälliger Meilenstein",
+ "dueDate": "Fälligkeitsdatum",
+ "saveInstallment": "Rate speichern",
+ "installmentError": "Die Rate konnte nicht gespeichert werden.",
+ "noInstallments": "Noch keine Ratenzahlungspläne.",
+ "allocated": "{{allocated}} von {{total}} zugewiesen",
+ "editLabel": "Bearbeiten {{label}}",
+ "deleteLabel": "{{label}} löschen",
+ "removeRemainingAmount": "Restbetrag entfernen — {{amount}} bleibt bezahlt",
+ "allocateLabel": "Ordnen Sie die Zahlung der Nummer {{label}} zu",
+ "allocateTo": "Ordnen Sie die Zahlung der Nummer {{label}} zu",
+ "movement": "Eingezogene Zahlung",
+ "allocate": "Zuordnen",
+ "allocationError": "Die Zahlung konnte nicht zugeordnet werden.",
+ "availableForAllocation": "{{available}} verfügbar · {{allocated}} zugeordnet · {{method}}",
+ "allocationAvailability": "{{available}} verfügbar · {{allocated}} zugeordnet",
+ "moveUp": "{{label}} nach oben verschieben",
+ "moveDown": "{{label}} nach unten verschieben",
+ "reorderError": "Die Reihenfolge der Raten konnte nicht gespeichert werden. Versuchen Sie es erneut.",
+ "movements": "Zahlungsvorgänge",
+ "noMovements": "Noch keine Zahlungsvorgänge.",
+ "savedCardProvenance": "Gespeicherte Karte · {{brand}} •••• {{lastFour}}",
+ "externalProvenance": "Extern · {{method}} · {{reference}}",
+ "loadError": "Zahlungsplan und Bewegungshistorie konnten nicht geladen werden.",
+ "folioSummary": "Zusammenfassung des operativen Folios",
+ "acceptedDeal": "Akzeptierter Gesamtbetrag",
+ "activeStayTotal": "Aktueller Aufenthaltspreis",
+ "folioCharges": "Folio-Posten",
+ "folioPayments": "Folio-Zahlungen",
+ "balanceDue": "Restbetrag fällig",
+ "folioError": "Die verknüpfte Foliozusammenfassung konnte nicht geladen werden."
+ },
+ "installmentStatuses": {
+ "unpaid": "Unbezahlt",
+ "partial": "Teilweise",
+ "paid": "Bezahlt"
+ },
+ "milestones": {
+ "manual": "Manuelle Nachverfolgung",
+ "arrival": "Fällig bei der Ankunft",
+ "checkout": "Fällig an der Kasse",
+ "date": "Spezifisches Datum",
+ "dateValue": "Fällig {{date}}"
+ },
+ "methods": {
+ "credit_card": "Kreditkarte",
+ "debit_card": "Debitkarte",
+ "cash": "Bargeld",
+ "bank_transfer": "Banküberweisung",
+ "pix": "PIX",
+ "other": "andere"
+ },
+ "paymentStatuses": {
+ "pending": "Ausstehend",
+ "authorized": "Autorisiert",
+ "captured": "Eingezogen",
+ "settled": "Verbucht",
+ "refunded": "Erstattet",
+ "partially_refunded": "Teilweise erstattet",
+ "failed": "Fehlgeschlagen",
+ "voided": "Entwertet"
+ },
+ "paymentActions": {
+ "amount": "Betrag",
+ "method": "Zahlungsart",
+ "processedAt": "Verarbeitet bei",
+ "provider": "Anbieter (optional)",
+ "reference": "Referenz",
+ "notes": "Notizen (optional)",
+ "saving": "Sparen…",
+ "error": "Der finanzielle Vorgang konnte nicht abgeschlossen werden. Ihre eingegebenen Daten sind noch vorhanden.",
+ "paymentRequired": "Wählen Sie zunächst eine Zahlungsbewegung aus.",
+ "charge": {
+ "title": "Gespeicherte Karte belasten",
+ "action": "Gespeicherte Karte belasten",
+ "description": "Diese Belastung wird ausdrücklich vom Personal ausgelöst. Die Anfrage wird dadurch nicht angenommen."
+ },
+ "external": {
+ "title": "Externe Zahlung erfassen",
+ "action": "Externe Zahlung erfassen",
+ "description": "Erfassen Sie Geld, das bereits außerhalb des Gateways für gespeicherte Karten gesammelt wurde."
+ },
+ "refund": {
+ "title": "Rückerstattung der gespeicherten Kartenzahlung",
+ "action": "Rückerstattung",
+ "description": "Geben Sie einen Teil oder die gesamte Gateway-Zahlung zurück."
+ },
+ "external_return": {
+ "title": "Externe Rückzahlung erfassen",
+ "action": "Rückzahlung erfassen",
+ "description": "Vermerken Sie, dass extern eingenommenes Geld zurückgegeben wurde."
+ },
+ "retain": {
+ "title": "Geld behalten",
+ "action": "Geld behalten",
+ "open": "Mit Begründung einbehalten",
+ "description": "Lösen Sie diesen Betrag vor der Ablehnung als einbehalten auf. Ein geschäftlicher Grund ist zwingend erforderlich.",
+ "reason": "Grund für die Geldeinbehaltung"
+ }
+ },
+ "messages": {
+ "title": "Nachrichtenübermittlungen",
+ "description": "Transaktions-E-Mails sind eine Folge von Mitarbeiteraktionen. Eine fehlgeschlagene Zustellung macht niemals eine Entscheidung oder einen Zahlungsvorgang rückgängig.",
+ "loading": "Nachrichtenverlauf wird geladen…",
+ "loadError": "Der Nachrichtenverlauf konnte nicht geladen werden.",
+ "empty": "Es wurden noch keine Transaktionsnachrichten aufgezeichnet.",
+ "retry": "Zustellung erneut versuchen",
+ "retryError": "Die fehlgeschlagene Zustellung konnte nicht wiederholt werden.",
+ "attempts_one": "{{count}} Versuch",
+ "attempts_other": "{{count}} Versuche",
+ "attempts": "{{count}} Versuche",
+ "actorNote": "Ein manueller Wiederholungsversuch wird mit Ihrer Mitarbeiteridentität aufgezeichnet."
+ },
+ "messageKinds": {
+ "receipt": "Quittung anfordern",
+ "accepted": "Akzeptanz",
+ "denied": "Ablehnung",
+ "payment": "Zahlung",
+ "refund": "Rückerstattung",
+ "failure": "Zahlungsfehler"
+ },
+ "messageStatuses": {
+ "pending": "Ausstehend",
+ "processing": "Verarbeitung",
+ "sent": "Gesendet",
+ "failed": "Fehlgeschlagen"
+ },
+ "audit": {
+ "title": "Geschäftszeitleiste",
+ "description": "Eine sichere Betriebsansicht aus den für das Personal verfügbaren Anfrage-, Zahlungs-, Klärungs- und Zustellprotokollen.",
+ "submitted": "Anfrage eingereicht",
+ "submittedDescription": "Die Gastanfrage und der eingereichte Angebots-Snapshot wurden aufgezeichnet.",
+ "accepted": "Anfrage angenommen",
+ "acceptedDescription": "Akzeptiert unter {{amount}} unter Verwendung der {{source}}.",
+ "denied": "Anfrage abgelehnt",
+ "deniedDescription": "Die Anfrage wurde abgelehnt, nachdem der Zahlungsstatus geklärt war.",
+ "paymentCaptured": "Zahlung erfasst",
+ "paymentFailed": "Die Zahlung ist fehlgeschlagen",
+ "paymentReturned": "Zahlung zurückgezahlt",
+ "paymentDescription": "{{amount}} · {{method}}",
+ "resolutionDescription": "{{amount}} gelöst",
+ "resolutions": {
+ "refund": "Gateway-Rückerstattung erfasst",
+ "external_return": "Externe Rückzahlung erfasst",
+ "retained": "Geld einbehalten"
+ },
+ "message": "{{kind}} Nachricht",
+ "messageDescription": "Lieferstatus: {{status}}",
+ "actor": "Ausgeführt von: {{actor}}",
+ "loadError": "Der Prüfverlauf konnte nicht geladen werden.",
+ "empty": "Es wurden noch keine Prüfereignisse aufgezeichnet.",
+ "loadMore": "Weitere laden",
+ "loadingMore": "Weitere werden geladen…",
+ "loadMoreError": "Weitere Audit-Ereignisse konnten nicht geladen werden.",
+ "events": {
+ "request_pending": "Anfrage eingereicht",
+ "request_accepted": "Anfrage angenommen",
+ "request_denied": "Anfrage abgelehnt",
+ "request_updated": "Anfrage aktualisiert",
+ "installment_created": "Rate erstellt",
+ "installment_updated": "Rate aktualisiert",
+ "installment_deleted": "Rate gelöscht",
+ "allocation_recorded": "Zahlung zugeordnet",
+ "allocation_removed": "Zahlungszuordnung entfernt",
+ "payment_pending": "Zahlung ausstehend",
+ "payment_captured": "Zahlung erfasst",
+ "payment_failed": "Zahlung fehlgeschlagen",
+ "payment_recorded": "Zahlung aufgezeichnet",
+ "payment_updated": "Zahlung aktualisiert",
+ "resolution_refund": "Rückerstattung erfasst",
+ "resolution_external_return": "Externe Rückzahlung erfasst",
+ "resolution_retained": "Geld einbehalten",
+ "resolution_recorded": "Zahlungsklärung erfasst",
+ "email_pending": "E-Mail eingereiht",
+ "email_processing": "E-Mail-Zustellung versucht",
+ "email_sent": "E-Mail zugestellt",
+ "email_failed": "E-Mail-Zustellung fehlgeschlagen",
+ "email_queued": "E-Mail eingereiht",
+ "email_updated": "E-Mail-Zustellung aktualisiert",
+ "stay_amended": "Angenommener Aufenthalt geändert"
+ }
+ }
}
}
diff --git a/apps/dashboard/src/locales/en.json b/apps/dashboard/src/locales/en.json
index 2172d12a..c9a039ab 100644
--- a/apps/dashboard/src/locales/en.json
+++ b/apps/dashboard/src/locales/en.json
@@ -144,7 +144,102 @@
"settingsSaved": "Booking engine settings saved",
"settingsSaveFailed": "Failed to save settings"
},
- "type": "Type"
+ "type": "Type",
+ "requestSettings": {
+ "autoConfirmDescription": "Auto-confirm applies only to instant bookings; booking requests always require a staff decision.",
+ "bookingMode": "Booking mode",
+ "backgroundLoadError": "Latest settings could not be checked. Your loaded settings are still available.",
+ "cardCollection": "Card collection",
+ "cardDescriptions": {
+ "disabled": "Guests submit without adding a card.",
+ "optional": "Guests choose whether to save a card for staff-initiated payments.",
+ "required": "Guests must save a card before submitting their request. No charge is made automatically."
+ },
+ "cardPolicies": {
+ "disabled": "Disabled",
+ "optional": "Optional",
+ "required": "Required"
+ },
+ "conflictDescription": "These settings changed since you opened this page. Your draft is still here. Reload the latest settings to review before editing again.",
+ "conflictTitle": "Settings conflict",
+ "description": "Choose whether guests book instantly or submit an application for your team to review.",
+ "engineToggle": "Enable direct booking engine",
+ "invalidQuestions": "Fix the guest form before saving these settings.",
+ "loadError": "Could not load booking engine settings.",
+ "loading": "Loading booking engine settings",
+ "modeDescriptions": {
+ "instant": "A successful checkout follows the existing instant-booking flow.",
+ "request": "Submission creates a pending request without confirming a reservation."
+ },
+ "modes": {
+ "instant": "Instant booking",
+ "request": "Booking request"
+ },
+ "requiredCardWarning": "Add a Stripe publishable key before enabling card collection.",
+ "unsupportedCardWarning": "The configured payment provider does not support saved cards. Disable card collection or choose Stripe.",
+ "reloadLatest": "Reload latest settings",
+ "reset": "Reset changes",
+ "retry": "Try again",
+ "save": "Save changes",
+ "saveError": "Could not save booking engine settings.",
+ "saving": "Saving changes",
+ "stripeKey": "Stripe publishable key",
+ "stripeKeyDescription": "Use the publishable key for this property. Stripe secret keys stay on the server.",
+ "stripeKeyPlaceholder": "pk_live_…",
+ "title": "Booking request workflow",
+ "unsaved": "Unsaved changes",
+ "unsupportedPublishBlocked": "Use a newer dashboard before publishing changes to this guest form."
+ },
+ "questions": {
+ "active": "Active",
+ "activeQuestion": "Active question",
+ "add": "Add question",
+ "addOption": "Add option",
+ "addTitle": "Add a question",
+ "cancel": "Cancel",
+ "cancelEditor": "Close question editor",
+ "count": "Questions: {{count}} / {{max}}",
+ "description": "Build the ordered application guests complete before submitting a booking request.",
+ "disable": "Disable {{label}}",
+ "duplicateIds": "Question IDs must be unique before this form can be edited.",
+ "edit": "Edit {{label}}",
+ "editTitle": "Edit question",
+ "emptyDescription": "Add only the information your team needs to review a request.",
+ "emptyTitle": "No application questions yet",
+ "enable": "Enable {{label}}",
+ "idError": "A unique question ID could not be created. Try again.",
+ "inactive": "Inactive",
+ "label": "Question label",
+ "labelRequired": "Enter a question label.",
+ "moveDown": "Move {{label}} down",
+ "moveOptionDown": "Move option {{number}} down",
+ "moveOptionUp": "Move option {{number}} up",
+ "moveUp": "Move {{label}} up",
+ "optionBlank": "Options cannot be blank.",
+ "optionDuplicate": "Options must be unique.",
+ "optionLabel": "Option {{number}}",
+ "optionRequired": "Add at least one option.",
+ "optional": "Optional",
+ "options": "Answer options",
+ "remove": "Remove {{label}}",
+ "removeOption": "Remove option {{number}}",
+ "required": "Required",
+ "requiredQuestion": "Required question",
+ "save": "Save question",
+ "title": "Guest form blueprint",
+ "type": "Question type",
+ "unsupportedActiveDescription": "This dashboard cannot safely change or republish the guest form while that question remains active.",
+ "unsupportedActiveTitle": "An unsupported question is active",
+ "unsupportedType": "Unsupported question",
+ "types": {
+ "short_text": "Short text",
+ "long_text": "Long text",
+ "single_select": "Single select",
+ "multi_select": "Multiple select",
+ "yes_no": "Yes / no",
+ "date": "Date"
+ }
+ }
},
"cashier": {
"amount": "Amount",
@@ -1122,6 +1217,7 @@
"dashboard": "Dashboard",
"foliosBilling": "Folios & Billing",
"frontDesk": "Front Desk",
+ "bookingRequests": "Booking Requests",
"groups": "Groups",
"guests": "Guests",
"houseAccounts": "House Accounts",
@@ -1751,6 +1847,382 @@
"percentage": "Percentage"
}
},
+ "bookingRequests": {
+ "access": {
+ "title": "Access restricted",
+ "description": "You need permission to read reservations before you can review booking requests."
+ },
+ "property": {
+ "title": "Choose one property",
+ "description": "Booking requests are reviewed one property at a time so guest, money, and decision records stay safely scoped."
+ },
+ "notFound": {
+ "title": "Request not found",
+ "description": "This request does not exist at the selected property or is no longer available."
+ },
+ "common": {
+ "cancel": "Cancel",
+ "loading": "Loading booking request…",
+ "status": "Status",
+ "yes": "Yes",
+ "no": "No",
+ "notProvided": "Not provided",
+ "retry": "Retry"
+ },
+ "actions": {
+ "accept": "Accept request",
+ "deny": "Deny request",
+ "charge": "Charge card",
+ "record": "Record payment"
+ },
+ "statuses": {
+ "pending": "Pending",
+ "accepted": "Accepted",
+ "denied": "Denied"
+ },
+ "tabs": {
+ "label": "Request workspace",
+ "overview": "Overview",
+ "payments": "Payments & plan",
+ "messages": "Messages",
+ "audit": "Audit"
+ },
+ "queue": {
+ "title": "Booking requests",
+ "description": "Review guest applications, stay details, and money state before taking an explicit action.",
+ "filters": "Booking request filters",
+ "guest": "Guest",
+ "card": "Card",
+ "stay": "Stay",
+ "amount": "Requested amount",
+ "allStatuses": "All statuses",
+ "anyCard": "Any card state",
+ "cardSaved": "Card saved",
+ "noCard": "No card",
+ "arrivalFrom": "Arrival from",
+ "arrivalTo": "Arrival to",
+ "sort": "Sort by",
+ "sortOptions": {
+ "newest": "Newest first",
+ "arrival": "Arrival date",
+ "guest": "Guest name",
+ "amountDesc": "Highest requested amount"
+ },
+ "clear": "Clear",
+ "loading": "Loading booking requests…",
+ "loadError": "Booking requests could not be loaded. Try again.",
+ "empty": "No booking requests match these filters.",
+ "total_one": "{{count}} request",
+ "total_other": "{{count}} requests",
+ "total": "{{count}} requests",
+ "previous": "Previous",
+ "next": "Next"
+ },
+ "detail": {
+ "back": "All booking requests",
+ "reference": "{{reference}} · Submitted {{date}}",
+ "decisionActions": "Decision",
+ "moneyActions": "Money",
+ "decisionComplete": "Decision complete",
+ "independence": "Booking decisions and money actions are independent. Taking payment never accepts a request, and acceptance never triggers payment."
+ },
+ "amounts": {
+ "quoted": "Quoted",
+ "submitted": "Submitted",
+ "current": "Current",
+ "accepted": "Accepted",
+ "difference": "Difference",
+ "captured": "Captured",
+ "returned": "Returned",
+ "retained": "Retained"
+ },
+ "overview": {
+ "stay": "Stay request",
+ "dates": "Dates",
+ "occupancy": "Occupancy",
+ "occupancyValue": "{{adults}} adults · {{children}} children",
+ "roomType": "Room type",
+ "ratePlan": "Rate plan",
+ "guest": "Guest & contact",
+ "email": "Email",
+ "phone": "Phone",
+ "specialRequests": "Special requests",
+ "application": "Application",
+ "noQuestions": "No additional application questions were submitted.",
+ "priceComparison": "Price comparison",
+ "card": "Card on file",
+ "cardGeneric": "card",
+ "noCard": "No saved card",
+ "cardSafety": "Only the safe card brand and last four digits are shown. Staff must initiate every charge.",
+ "decision": "Decision record",
+ "priceSource": "Price source"
+ },
+ "priceSources": {
+ "submitted": "Submitted quote",
+ "current": "Current quote",
+ "custom": "Custom total"
+ },
+ "accept": {
+ "title": "Accept booking request",
+ "priceChoice": "Accepted price",
+ "submitted": "Submitted quote",
+ "submittedDescription": "Use the amount the guest saw when the request was sent.",
+ "current": "Current quote",
+ "currentDescription": "Recheck availability and calculate the authoritative current quote during acceptance.",
+ "custom": "Custom total",
+ "customDescription": "Set an explicit total and record why it differs.",
+ "recheckedOnAccept": "Rechecked on acceptance",
+ "enterAmount": "Enter an amount",
+ "customTotal": "Custom total",
+ "customReason": "Reason for custom total",
+ "independence": "Accepting creates the reservation. It does not charge the saved card or depend on payment state.",
+ "accepting": "Accepting…",
+ "error": "The request could not be accepted. Review the current availability and try again."
+ },
+ "modifyStay": {
+ "action": "Modify stay",
+ "title": "Modify accepted stay",
+ "activeStay": "Active stay",
+ "proposedStay": "Proposed stay",
+ "originalRequest": "Original request",
+ "arrivalDate": "Arrival date",
+ "departureDate": "Departure date",
+ "invalidDates": "Departure must be after arrival and both dates must be valid.",
+ "checking": "Checking the complete stay and current quote…",
+ "previewError": "This stay cannot be quoted right now. Review availability and try again.",
+ "priceChoice": "Rate basis for the amended stay",
+ "prior": "Prior accepted basis",
+ "priorDescription": "Keep exact accepted lines on overlapping dates; extension nights use the nearest accepted boundary rate.",
+ "current": "Current quote",
+ "currentDescription": "Use the authoritative rates available for the complete proposed stay now.",
+ "custom": "Custom total",
+ "customDescription": "Set a positive operational total and record the reason.",
+ "awaitingQuote": "Awaiting quote",
+ "enterAmount": "Enter an amount",
+ "customTotal": "Custom total",
+ "customReason": "Reason for custom total",
+ "apply": "Apply stay change",
+ "applying": "Applying…",
+ "commitError": "The stay could not be changed. Review the latest availability and quote."
+ },
+ "deny": {
+ "title": "Deny booking request",
+ "reason": "Denial reason",
+ "unresolved": "{{amount}} remains unresolved",
+ "unresolvedDirection": "Refund a saved-card payment, record an external return, or retain the amount with a reason before denying.",
+ "resolveFirst": "Resolve money first",
+ "confirm": "Confirm denial",
+ "denying": "Denying…",
+ "error": "The request could not be denied. Review its money state and try again.",
+ "moneyLoading": "Verifying payment state before denial…",
+ "moneyLoadError": "Payment state could not be verified. Denial remains blocked.",
+ "retryMoney": "Retry payment state"
+ },
+ "validation": {
+ "positiveAmount": "Enter an amount greater than zero.",
+ "required": "Enter an amount.",
+ "format": "Enter a valid decimal amount.",
+ "positive": "Enter an amount greater than zero.",
+ "precision": "Use only the minor units supported by this currency.",
+ "unsupportedCurrency": "This currency's minor units are not supported by the ledger.",
+ "maximum": "Enter an amount within the allowed maximum."
+ },
+ "payments": {
+ "requestSummary": "Request money summary",
+ "independence": "Booking decisions do not depend on payment state.",
+ "plan": "Payment plan",
+ "noAutomatic": "Nothing is charged automatically. Milestones are reminders for staff-initiated actions.",
+ "noAutomaticTitle": "Nothing is charged automatically.",
+ "noAutomaticDescription": "Milestones are reminders for staff-initiated actions.",
+ "addInstallment": "Add installment",
+ "editInstallment": "Edit installment",
+ "installmentLabel": "Installment label",
+ "amountType": "Amount type",
+ "fixedAmount": "Fixed amount",
+ "percentage": "Percentage",
+ "milestone": "Due milestone",
+ "dueDate": "Due date",
+ "saveInstallment": "Save installment",
+ "installmentError": "The installment could not be saved.",
+ "noInstallments": "No payment-plan installments yet.",
+ "allocated": "{{allocated}} of {{total}} allocated",
+ "editLabel": "Edit {{label}}",
+ "deleteLabel": "Delete {{label}}",
+ "removeRemainingAmount": "Remove remaining amount — {{amount}} will remain paid",
+ "allocateLabel": "Allocate payment to {{label}}",
+ "allocateTo": "Allocate payment to {{label}}",
+ "movement": "Captured movement",
+ "allocate": "Allocate",
+ "allocationError": "The payment could not be allocated.",
+ "availableForAllocation": "{{available}} available · {{allocated}} allocated · {{method}}",
+ "allocationAvailability": "{{available}} available · {{allocated}} allocated",
+ "moveUp": "Move {{label}} up",
+ "moveDown": "Move {{label}} down",
+ "reorderError": "The installment order could not be saved. Try again.",
+ "movements": "Money movements",
+ "noMovements": "No money movements yet.",
+ "savedCardProvenance": "Saved card · {{brand}} •••• {{lastFour}}",
+ "externalProvenance": "External · {{method}} · {{reference}}",
+ "loadError": "Payment-plan and movement history could not be loaded.",
+ "folioSummary": "Operational folio summary",
+ "acceptedDeal": "Accepted deal",
+ "activeStayTotal": "Active stay total",
+ "folioCharges": "Folio charges",
+ "folioPayments": "Folio payments",
+ "balanceDue": "Balance due",
+ "folioError": "The linked folio summary could not be loaded."
+ },
+ "installmentStatuses": {
+ "unpaid": "Unpaid",
+ "partial": "Partial",
+ "paid": "Paid"
+ },
+ "milestones": {
+ "manual": "Manual follow-up",
+ "arrival": "Due at arrival",
+ "checkout": "Due at checkout",
+ "date": "Specific date",
+ "dateValue": "Due {{date}}"
+ },
+ "methods": {
+ "credit_card": "credit card",
+ "debit_card": "debit card",
+ "cash": "cash",
+ "bank_transfer": "bank transfer",
+ "pix": "PIX",
+ "other": "other"
+ },
+ "paymentStatuses": {
+ "pending": "Pending",
+ "authorized": "Authorized",
+ "captured": "Captured",
+ "settled": "Settled",
+ "refunded": "Refunded",
+ "partially_refunded": "Partially refunded",
+ "failed": "Failed",
+ "voided": "Voided"
+ },
+ "paymentActions": {
+ "amount": "Amount",
+ "method": "Payment method",
+ "processedAt": "Processed at",
+ "provider": "Provider (optional)",
+ "reference": "Reference",
+ "notes": "Notes (optional)",
+ "saving": "Saving…",
+ "error": "The money action could not be completed. Your entered details are still here.",
+ "paymentRequired": "Choose a payment movement first.",
+ "charge": {
+ "title": "Charge saved card",
+ "action": "Charge saved card",
+ "description": "This is an explicit staff-initiated charge. It does not accept the request."
+ },
+ "external": {
+ "title": "Record external payment",
+ "action": "Record external payment",
+ "description": "Record money already collected outside the saved-card gateway."
+ },
+ "refund": {
+ "title": "Refund saved-card payment",
+ "action": "Refund",
+ "description": "Return part or all of this gateway payment."
+ },
+ "external_return": {
+ "title": "Record external return",
+ "action": "Record return",
+ "description": "Record that externally collected money has been returned."
+ },
+ "retain": {
+ "title": "Retain money",
+ "action": "Retain money",
+ "open": "Retain with reason",
+ "description": "Resolve this amount as retained before denial. A business reason is mandatory.",
+ "reason": "Reason for retaining money"
+ }
+ },
+ "messages": {
+ "title": "Message deliveries",
+ "description": "Transactional email is a consequence of staff actions. A delivery failure never reverses a decision or money movement.",
+ "loading": "Loading message history…",
+ "loadError": "Message history could not be loaded.",
+ "empty": "No transactional messages have been recorded yet.",
+ "retry": "Retry delivery",
+ "retryError": "The failed delivery could not be retried.",
+ "attempts_one": "{{count}} attempt",
+ "attempts_other": "{{count}} attempts",
+ "attempts": "{{count}} attempts",
+ "actorNote": "A manual retry is recorded with your staff identity."
+ },
+ "messageKinds": {
+ "receipt": "Request receipt",
+ "accepted": "Acceptance",
+ "denied": "Denial",
+ "payment": "Payment",
+ "refund": "Refund",
+ "failure": "Payment failure"
+ },
+ "messageStatuses": {
+ "pending": "Pending",
+ "processing": "Processing",
+ "sent": "Sent",
+ "failed": "Failed"
+ },
+ "audit": {
+ "title": "Business timeline",
+ "description": "A safe operational view derived from the request, movement, resolution, and delivery records available to staff.",
+ "submitted": "Request submitted",
+ "submittedDescription": "The guest application and submitted quote snapshot were recorded.",
+ "accepted": "Request accepted",
+ "acceptedDescription": "Accepted at {{amount}} using the {{source}}.",
+ "denied": "Request denied",
+ "deniedDescription": "The request was denied after its money state was resolved.",
+ "paymentCaptured": "Payment captured",
+ "paymentFailed": "Payment failed",
+ "paymentReturned": "Payment returned",
+ "paymentDescription": "{{amount}} · {{method}}",
+ "resolutionDescription": "{{amount}} resolved",
+ "resolutions": {
+ "refund": "Gateway refund recorded",
+ "external_return": "External return recorded",
+ "retained": "Money retained"
+ },
+ "message": "{{kind}} message",
+ "messageDescription": "Delivery status: {{status}}",
+ "actor": "Performed by: {{actor}}",
+ "loadError": "The audit history could not be loaded.",
+ "empty": "No audit events have been recorded.",
+ "loadMore": "Load more",
+ "loadingMore": "Loading more…",
+ "loadMoreError": "More audit events could not be loaded.",
+ "events": {
+ "request_pending": "Request submitted",
+ "request_accepted": "Request accepted",
+ "request_denied": "Request denied",
+ "request_updated": "Request updated",
+ "installment_created": "Installment created",
+ "installment_updated": "Installment updated",
+ "installment_deleted": "Installment deleted",
+ "allocation_recorded": "Payment allocated",
+ "allocation_removed": "Payment allocation removed",
+ "payment_pending": "Payment pending",
+ "payment_captured": "Payment captured",
+ "payment_failed": "Payment failed",
+ "payment_recorded": "Payment recorded",
+ "payment_updated": "Payment updated",
+ "resolution_refund": "Refund recorded",
+ "resolution_external_return": "External return recorded",
+ "resolution_retained": "Money retained",
+ "resolution_recorded": "Payment resolution recorded",
+ "email_pending": "Email queued",
+ "email_processing": "Email delivery attempted",
+ "email_sent": "Email delivered",
+ "email_failed": "Email delivery failed",
+ "email_queued": "Email queued",
+ "email_updated": "Email delivery updated",
+ "stay_amended": "Accepted stay amended"
+ }
+ }
+ },
"errors": {
"Request failed": "Request failed",
"Network error. Please check your connection.": "Network error. Please check your connection.",
diff --git a/apps/dashboard/src/locales/es.json b/apps/dashboard/src/locales/es.json
index bfed085d..72b44a09 100644
--- a/apps/dashboard/src/locales/es.json
+++ b/apps/dashboard/src/locales/es.json
@@ -144,7 +144,102 @@
"settingsSaved": "Configuración del motor de reservas guardada",
"settingsSaveFailed": "Error al guardar la configuración"
},
- "type": "Tipo"
+ "type": "Tipo",
+ "requestSettings": {
+ "autoConfirmDescription": "La confirmación automática solo se aplica a reservas instantáneas; las peticiones siempre requieren una decisión del personal.",
+ "bookingMode": "Modo de reserva",
+ "backgroundLoadError": "No se pudo comprobar la configuración más reciente. La configuración cargada sigue disponible.",
+ "cardCollection": "Recopilación de tarjeta",
+ "cardDescriptions": {
+ "disabled": "Los huéspedes envían la petición sin añadir una tarjeta.",
+ "optional": "Los huéspedes eligen si desean guardar una tarjeta para pagos iniciados por el personal.",
+ "required": "Los huéspedes deben guardar una tarjeta antes de enviar la petición. No se realiza ningún cargo automático."
+ },
+ "cardPolicies": {
+ "disabled": "Desactivada",
+ "optional": "Opcional",
+ "required": "Obligatoria"
+ },
+ "conflictDescription": "Esta configuración cambió desde que abrió la página. Su borrador sigue aquí. Recargue la configuración más reciente antes de volver a editar.",
+ "conflictTitle": "Conflicto de configuración",
+ "description": "Elija si los huéspedes reservan al instante o envían una solicitud para que su equipo la revise.",
+ "engineToggle": "Activar el motor de reserva directa",
+ "invalidQuestions": "Corrija el formulario del huésped antes de guardar estos ajustes.",
+ "loadError": "No se pudo cargar la configuración del motor de reservas.",
+ "loading": "Cargando la configuración del motor de reservas",
+ "modeDescriptions": {
+ "instant": "Un pago correcto sigue el flujo actual de reserva instantánea.",
+ "request": "El envío crea una petición pendiente sin confirmar una reserva."
+ },
+ "modes": {
+ "instant": "Reserva instantánea",
+ "request": "Petición de reserva"
+ },
+ "requiredCardWarning": "Añada una clave publicable de Stripe antes de exigir una tarjeta.",
+ "unsupportedCardWarning": "El proveedor de pagos configurado no admite tarjetas guardadas. Desactive la recopilación de tarjetas o elija Stripe.",
+ "reloadLatest": "Recargar la configuración más reciente",
+ "reset": "Restablecer cambios",
+ "retry": "Intentar de nuevo",
+ "save": "Guardar cambios",
+ "saveError": "No se pudo guardar la configuración del motor de reservas.",
+ "saving": "Guardando cambios",
+ "stripeKey": "Clave publicable de Stripe",
+ "stripeKeyDescription": "Use la clave publicable de esta propiedad. Las claves secretas de Stripe permanecen en el servidor.",
+ "stripeKeyPlaceholder": "pk_live_…",
+ "title": "Flujo de peticiones de reserva",
+ "unsaved": "Cambios sin guardar",
+ "unsupportedPublishBlocked": "Use una versión más reciente del panel antes de publicar cambios en este formulario de huéspedes."
+ },
+ "questions": {
+ "active": "Activa",
+ "activeQuestion": "Pregunta activa",
+ "add": "Añadir pregunta",
+ "addOption": "Añadir opción",
+ "addTitle": "Añadir una pregunta",
+ "cancel": "Cancelar",
+ "cancelEditor": "Cerrar el editor de preguntas",
+ "count": "Preguntas: {{count}} / {{max}}",
+ "description": "Cree la solicitud ordenada que los huéspedes completan antes de enviar una petición de reserva.",
+ "disable": "Desactivar {{label}}",
+ "duplicateIds": "Los identificadores de las preguntas deben ser únicos para poder editar este formulario.",
+ "edit": "Editar {{label}}",
+ "editTitle": "Editar pregunta",
+ "emptyDescription": "Añada solo la información que su equipo necesita para revisar una petición.",
+ "emptyTitle": "Aún no hay preguntas en la solicitud",
+ "enable": "Activar {{label}}",
+ "idError": "No se pudo crear un identificador único. Inténtelo de nuevo.",
+ "inactive": "Inactiva",
+ "label": "Texto de la pregunta",
+ "labelRequired": "Introduzca el texto de la pregunta.",
+ "moveDown": "Bajar {{label}}",
+ "moveOptionDown": "Bajar la opción {{number}}",
+ "moveOptionUp": "Subir la opción {{number}}",
+ "moveUp": "Subir {{label}}",
+ "optionBlank": "Las opciones no pueden estar vacías.",
+ "optionDuplicate": "Las opciones deben ser únicas.",
+ "optionLabel": "Opción {{number}}",
+ "optionRequired": "Añada al menos una opción.",
+ "optional": "Opcional",
+ "options": "Opciones de respuesta",
+ "remove": "Eliminar {{label}}",
+ "removeOption": "Eliminar la opción {{number}}",
+ "required": "Obligatoria",
+ "requiredQuestion": "Pregunta obligatoria",
+ "save": "Guardar pregunta",
+ "title": "Estructura del formulario del huésped",
+ "type": "Tipo de pregunta",
+ "unsupportedActiveDescription": "Este panel no puede cambiar ni volver a publicar el formulario de huéspedes de forma segura mientras esa pregunta siga activa.",
+ "unsupportedActiveTitle": "Hay una pregunta no compatible activa",
+ "unsupportedType": "Pregunta no compatible",
+ "types": {
+ "short_text": "Texto corto",
+ "long_text": "Texto largo",
+ "single_select": "Selección única",
+ "multi_select": "Selección múltiple",
+ "yes_no": "Sí / no",
+ "date": "Fecha"
+ }
+ }
},
"cashier": {
"amount": "Monto",
@@ -1122,6 +1217,7 @@
"dashboard": "Panel",
"foliosBilling": "Folios y facturación",
"frontDesk": "Recepción",
+ "bookingRequests": "Solicitudes de reserva",
"groups": "Grupos",
"guests": "Huéspedes",
"houseAccounts": "Cuentas house",
@@ -1771,5 +1867,364 @@
"Bad Request": "Solicitud incorrecta",
"Resource not found": "Recurso no encontrado",
"Action not permitted": "Acción no permitida"
+ },
+ "bookingRequests": {
+ "access": {
+ "title": "Acceso restringido",
+ "description": "Necesita permiso para leer las reservas antes de poder revisar las solicitudes de reserva."
+ },
+ "property": {
+ "title": "Elija una propiedad",
+ "description": "Las solicitudes de reserva se revisan una propiedad a la vez para que los registros de huéspedes, dinero y decisiones se mantengan a salvo."
+ },
+ "notFound": {
+ "title": "Solicitud no encontrada",
+ "description": "Esta solicitud no existe en la propiedad seleccionada o ya no está disponible."
+ },
+ "common": {
+ "cancel": "Cancelar",
+ "loading": "Cargando solicitud de reserva…",
+ "status": "Estado",
+ "yes": "Sí",
+ "no": "No",
+ "notProvided": "No proporcionado",
+ "retry": "Reintentar"
+ },
+ "actions": {
+ "accept": "Aceptar solicitud",
+ "deny": "Rechazar solicitud",
+ "charge": "Cobrar a la tarjeta",
+ "record": "Registrar pago"
+ },
+ "statuses": {
+ "pending": "Pendiente",
+ "accepted": "Aceptado",
+ "denied": "denegado"
+ },
+ "tabs": {
+ "label": "Solicitar espacio de trabajo",
+ "overview": "Descripción general",
+ "payments": "Pagos y planes",
+ "messages": "Mensajes",
+ "audit": "Auditoría"
+ },
+ "queue": {
+ "title": "Solicitudes de reserva",
+ "description": "Revise las solicitudes de los huéspedes, los detalles de la estadía y el estado del dinero antes de realizar una acción explícita.",
+ "filters": "Filtros de solicitud de reserva",
+ "guest": "Invitado",
+ "card": "Tarjeta",
+ "stay": "quedarse",
+ "amount": "Monto solicitado",
+ "allStatuses": "Todos los estados",
+ "anyCard": "Cualquier estado de tarjeta",
+ "cardSaved": "Tarjeta guardada",
+ "noCard": "sin tarjeta",
+ "arrivalFrom": "Llegada desde",
+ "arrivalTo": "Llegada a",
+ "sort": "Ordenar por",
+ "sortOptions": {
+ "newest": "Lo nuevo primero",
+ "arrival": "Fecha de llegada",
+ "guest": "Nombre del invitado",
+ "amountDesc": "Importe solicitado más alto"
+ },
+ "clear": "Borrar",
+ "loading": "Cargando solicitudes de reserva…",
+ "loadError": "No se pudieron cargar las solicitudes de reserva. Intentar otra vez.",
+ "empty": "Ninguna solicitud de reserva coincide con estos filtros.",
+ "total_one": "{{count}} solicitud",
+ "total_other": "{{count}} solicitudes",
+ "total": "{{count}} solicitudes",
+ "previous": "Anterior",
+ "next": "Siguiente"
+ },
+ "detail": {
+ "back": "Todas las solicitudes de reserva",
+ "reference": "{{reference}} · Enviado {{date}}",
+ "decisionActions": "decisión",
+ "moneyActions": "dinero",
+ "decisionComplete": "Decisión completa",
+ "independence": "Las decisiones de reserva y las acciones monetarias son independientes. Al aceptar el pago nunca se acepta una solicitud y la aceptación nunca activa el pago."
+ },
+ "amounts": {
+ "quoted": "Cotización",
+ "submitted": "Enviado",
+ "current": "Actual",
+ "accepted": "Aceptado",
+ "difference": "Diferencia",
+ "captured": "Cobrado",
+ "returned": "Devuelto",
+ "retained": "Retenido"
+ },
+ "overview": {
+ "stay": "Solicitud de estancia",
+ "dates": "Fechas",
+ "occupancy": "Ocupación",
+ "occupancyValue": "{{adults}} adultos · {{children}} niños",
+ "roomType": "Tipo de habitación",
+ "ratePlan": "plan tarifario",
+ "guest": "Invitado y contacto",
+ "email": "Correo electrónico",
+ "phone": "Teléfono",
+ "specialRequests": "Solicitudes especiales",
+ "application": "Solicitud",
+ "noQuestions": "No se enviaron preguntas de solicitud adicionales.",
+ "priceComparison": "Comparación de precios",
+ "card": "Tarjeta registrada",
+ "cardGeneric": "tarjeta",
+ "noCard": "Ninguna tarjeta guardada",
+ "cardSafety": "Sólo se muestran la marca de la tarjeta segura y los últimos cuatro dígitos. El personal debe iniciar cada cargo.",
+ "decision": "Registro de decisiones",
+ "priceSource": "fuente de precio"
+ },
+ "priceSources": {
+ "submitted": "cotización enviada",
+ "current": "Cotización actual",
+ "custom": "Total personalizado"
+ },
+ "accept": {
+ "title": "Aceptar solicitud de reserva",
+ "priceChoice": "Precio aceptado",
+ "submitted": "cotización enviada",
+ "submittedDescription": "Utilice la cantidad que vio el huésped cuando se envió la solicitud.",
+ "current": "Cotización actual",
+ "currentDescription": "Vuelva a verificar la disponibilidad y calcule la cotización actual autorizada durante la aceptación.",
+ "custom": "Total personalizado",
+ "customDescription": "Establezca un total explícito y registre por qué difiere.",
+ "recheckedOnAccept": "Se vuelve a comprobar al aceptar",
+ "enterAmount": "Introduce una cantidad",
+ "customTotal": "Total personalizado",
+ "customReason": "Motivo del total personalizado",
+ "independence": "Al aceptar se crea la reserva. No carga la tarjeta guardada ni depende del estado de pago.",
+ "accepting": "Aceptando…",
+ "error": "La solicitud no pudo ser aceptada. Revise la disponibilidad actual y vuelva a intentarlo."
+ },
+ "modifyStay": {
+ "action": "Modificar estancia", "title": "Modificar estancia aceptada", "activeStay": "Estancia activa", "proposedStay": "Estancia propuesta", "originalRequest": "Solicitud original",
+ "arrivalDate": "Fecha de llegada", "departureDate": "Fecha de salida", "invalidDates": "La salida debe ser posterior a la llegada y ambas fechas deben ser válidas.",
+ "checking": "Comprobando la estancia completa y el precio actual…", "previewError": "No se puede calcular esta estancia ahora. Revise la disponibilidad e inténtelo de nuevo.",
+ "priceChoice": "Base de tarifa para la estancia modificada", "prior": "Base aceptada anterior", "priorDescription": "Las fechas coincidentes conservan sus líneas aceptadas; las noches añadidas usan la tarifa límite aceptada más cercana.",
+ "current": "Cotización actual", "currentDescription": "Use las tarifas autorizadas disponibles ahora para toda la estancia propuesta.", "custom": "Total personalizado", "customDescription": "Defina un total operativo positivo y registre el motivo.",
+ "awaitingQuote": "Esperando cotización", "enterAmount": "Introduzca un importe", "customTotal": "Total personalizado", "customReason": "Motivo del total personalizado",
+ "apply": "Aplicar cambio de estancia", "applying": "Aplicando…", "commitError": "No se pudo cambiar la estancia. Revise la disponibilidad y la cotización más recientes."
+ },
+ "deny": {
+ "title": "Denegar solicitud de reserva",
+ "reason": "Motivo de denegación",
+ "unresolved": "{{amount}} sigue sin resolverse",
+ "unresolvedDirection": "Reembolsar un pago con tarjeta guardada, registrar una devolución externa o retener el monto con un motivo antes de rechazarlo.",
+ "resolveFirst": "Resolver el dinero primero",
+ "confirm": "Confirmar denegación",
+ "denying": "Rechazando…",
+ "error": "La solicitud no pudo ser rechazada. Revise su estado de pagos e inténtelo de nuevo.",
+ "moneyLoading": "Verificando el estado de los pagos antes de rechazar…",
+ "moneyLoadError": "No se pudo verificar el estado de los pagos. El rechazo sigue bloqueado.",
+ "retryMoney": "Volver a verificar los pagos"
+ },
+ "validation": {
+ "positiveAmount": "Introduzca un importe mayor que cero.",
+ "required": "Introduzca un importe.",
+ "format": "Introduzca un importe decimal válido.",
+ "positive": "Introduzca un importe mayor que cero.",
+ "precision": "Use solo los decimales admitidos por esta moneda.",
+ "unsupportedCurrency": "El libro mayor no admite los decimales de esta moneda.",
+ "maximum": "Introduzca un importe dentro del máximo permitido."
+ },
+ "payments": {
+ "requestSummary": "Resumen de pagos de la solicitud",
+ "independence": "Las decisiones de reserva no dependen del estado de pago.",
+ "plan": "plan de pago",
+ "noAutomatic": "No se cobra nada automáticamente. Los hitos son recordatorios de las acciones iniciadas por el personal.",
+ "noAutomaticTitle": "No se cobra nada automáticamente.",
+ "noAutomaticDescription": "Los hitos son recordatorios de las acciones iniciadas por el personal.",
+ "addInstallment": "Agregar cuota",
+ "editInstallment": "Editar cuota",
+ "installmentLabel": "etiqueta de pago a plazos",
+ "amountType": "Tipo de importe",
+ "fixedAmount": "Cantidad fija",
+ "percentage": "Porcentaje",
+ "milestone": "hito debido",
+ "dueDate": "fecha de vencimiento",
+ "saveInstallment": "Guardar cuota",
+ "installmentError": "No se pudo guardar la cuota.",
+ "noInstallments": "Aún no hay cuotas del plan de pago.",
+ "allocated": "{{allocated}} de {{total}} asignados",
+ "editLabel": "Editar {{label}}",
+ "deleteLabel": "Eliminar {{label}}",
+ "removeRemainingAmount": "Eliminar importe restante — {{amount}} seguirá pagado",
+ "allocateLabel": "Asignar pago al {{label}}",
+ "allocateTo": "Asignar pago al {{label}}",
+ "movement": "Pago cobrado",
+ "allocate": "Asignar",
+ "allocationError": "No se pudo asignar el pago.",
+ "availableForAllocation": "{{available}} disponible · {{allocated}} asignado · {{method}}",
+ "allocationAvailability": "{{available}} disponible · {{allocated}} asignado",
+ "moveUp": "Subir {{label}}",
+ "moveDown": "Bajar {{label}}",
+ "reorderError": "No se pudo guardar el orden de las cuotas. Inténtelo de nuevo.",
+ "movements": "Movimientos de pago",
+ "noMovements": "Aún no hay movimientos de pago.",
+ "savedCardProvenance": "Tarjeta guardada · {{brand}} •••• {{lastFour}}",
+ "externalProvenance": "Externo · {{method}} · {{reference}}",
+ "loadError": "No se pudo cargar el plan de pagos ni el historial de movimientos.",
+ "folioSummary": "Resumen del folio operativo",
+ "acceptedDeal": "Total aceptado",
+ "activeStayTotal": "Total de estancia activa",
+ "folioCharges": "Cargos en folio",
+ "folioPayments": "Pagos en folio",
+ "balanceDue": "Saldo adeudado",
+ "folioError": "No se pudo cargar el resumen del folio vinculado."
+ },
+ "installmentStatuses": {
+ "unpaid": "Pendiente de pago",
+ "partial": "Parcial",
+ "paid": "Pagado"
+ },
+ "milestones": {
+ "manual": "Seguimiento manual",
+ "arrival": "A pagar a la llegada",
+ "checkout": "Vencimiento al finalizar la compra",
+ "date": "fecha especifica",
+ "dateValue": "Vencimiento {{date}}"
+ },
+ "methods": {
+ "credit_card": "tarjeta de crédito",
+ "debit_card": "tarjeta de débito",
+ "cash": "efectivo",
+ "bank_transfer": "transferencia bancaria",
+ "pix": "PIX",
+ "other": "otro"
+ },
+ "paymentStatuses": {
+ "pending": "Pendiente",
+ "authorized": "Autorizado",
+ "captured": "Cobrado",
+ "settled": "Contabilizado",
+ "refunded": "Reembolsado",
+ "partially_refunded": "Reembolsado parcialmente",
+ "failed": "Fallido",
+ "voided": "anulado"
+ },
+ "paymentActions": {
+ "amount": "Cantidad",
+ "method": "Método de pago",
+ "processedAt": "Procesado en",
+ "provider": "Proveedor (opcional)",
+ "reference": "Referencia",
+ "notes": "Notas (opcional)",
+ "saving": "Guardando…",
+ "error": "La acción monetaria no se pudo completar. Los datos ingresados todavía están aquí.",
+ "paymentRequired": "Elija primero un movimiento de pago.",
+ "charge": {
+ "title": "Cobrar a la tarjeta guardada",
+ "action": "Cobrar a la tarjeta guardada",
+ "description": "Este es un cargo explícito iniciado por el personal. No acepta la solicitud."
+ },
+ "external": {
+ "title": "Registrar pago externo",
+ "action": "Registrar pago externo",
+ "description": "Registre el dinero ya recaudado fuera del portal de tarjetas guardadas."
+ },
+ "refund": {
+ "title": "Reembolso de pago con tarjeta guardada",
+ "action": "Reembolso",
+ "description": "Devuelva una parte o la totalidad de este pago con tarjeta."
+ },
+ "external_return": {
+ "title": "Registrar devolución externa",
+ "action": "Registrar devolución",
+ "description": "Registre que el dinero recaudado externamente ha sido devuelto."
+ },
+ "retain": {
+ "title": "Retener el importe",
+ "action": "Retener el importe",
+ "open": "Retener con motivo",
+ "description": "Resuelva esta cantidad como retenida antes de la denegación. Un motivo comercial es obligatorio.",
+ "reason": "Razón para retener dinero"
+ }
+ },
+ "messages": {
+ "title": "Entregas de mensajes",
+ "description": "El correo electrónico transaccional es consecuencia de las acciones del personal. Una falla en la entrega nunca revierte una decisión o movimiento de dinero.",
+ "loading": "Cargando historial de mensajes…",
+ "loadError": "No se pudo cargar el historial de mensajes.",
+ "empty": "Aún no se han registrado mensajes transaccionales.",
+ "retry": "Reintentar entrega",
+ "retryError": "No se pudo volver a intentar la entrega fallida.",
+ "attempts_one": "{{count}} intento",
+ "attempts_other": "{{count}} intentos",
+ "attempts": "{{count}} intentos",
+ "actorNote": "Un reintento manual se registra con la identidad de su personal."
+ },
+ "messageKinds": {
+ "receipt": "Solicitar recibo",
+ "accepted": "Aceptación",
+ "denied": "Rechazo",
+ "payment": "Pago",
+ "refund": "Reembolso",
+ "failure": "Fallo de pago"
+ },
+ "messageStatuses": {
+ "pending": "Pendiente",
+ "processing": "Procesamiento",
+ "sent": "Enviado",
+ "failed": "Fallido"
+ },
+ "audit": {
+ "title": "Cronograma empresarial",
+ "description": "Una visión operativa segura derivada de los registros de solicitudes, movimientos, resoluciones y entregas a disposición del personal.",
+ "submitted": "Solicitud enviada",
+ "submittedDescription": "Se registraron la solicitud de invitado y la instantánea de la cotización enviada.",
+ "accepted": "Solicitud aceptada",
+ "acceptedDescription": "Aceptado al {{amount}} utilizando el {{source}}.",
+ "denied": "Solicitud denegada",
+ "deniedDescription": "La solicitud fue denegada después de que se resolvió su estado monetario.",
+ "paymentCaptured": "Pago cobrado",
+ "paymentFailed": "Pago fallido",
+ "paymentReturned": "Pago devuelto",
+ "paymentDescription": "{{amount}} · {{method}}",
+ "resolutionDescription": "{{amount}} resuelto",
+ "resolutions": {
+ "refund": "Reembolso de tarjeta registrado",
+ "external_return": "Devolución externa registrada",
+ "retained": "Dinero retenido"
+ },
+ "message": "{{kind}} mensaje",
+ "messageDescription": "Estado de entrega: {{status}}",
+ "actor": "Realizado por: {{actor}}",
+ "loadError": "No se pudo cargar el historial de auditoría.",
+ "empty": "No se han registrado eventos de auditoría.",
+ "loadMore": "Cargar más",
+ "loadingMore": "Cargando más eventos…",
+ "loadMoreError": "No se pudieron cargar más eventos de auditoría.",
+ "events": {
+ "request_pending": "Solicitud enviada",
+ "request_accepted": "Solicitud aceptada",
+ "request_denied": "Solicitud rechazada",
+ "request_updated": "Solicitud actualizada",
+ "installment_created": "Cuota creada",
+ "installment_updated": "Cuota actualizada",
+ "installment_deleted": "Cuota eliminada",
+ "allocation_recorded": "Pago asignado",
+ "allocation_removed": "Asignación de pago eliminada",
+ "payment_pending": "Pago pendiente",
+ "payment_captured": "Pago cobrado",
+ "payment_failed": "Pago fallido",
+ "payment_recorded": "Pago registrado",
+ "payment_updated": "Pago actualizado",
+ "resolution_refund": "Reembolso registrado",
+ "resolution_external_return": "Devolución externa registrada",
+ "resolution_retained": "Dinero retenido",
+ "resolution_recorded": "Resolución de pago registrada",
+ "email_pending": "Correo en cola",
+ "email_processing": "Entrega de correo intentada",
+ "email_sent": "Correo entregado",
+ "email_failed": "Error en la entrega del correo",
+ "email_queued": "Correo en cola",
+ "email_updated": "Entrega de correo actualizada",
+ "stay_amended": "Estancia aceptada modificada"
+ }
+ }
}
}
diff --git a/apps/dashboard/src/locales/fr.json b/apps/dashboard/src/locales/fr.json
index acc945a9..10d434f5 100644
--- a/apps/dashboard/src/locales/fr.json
+++ b/apps/dashboard/src/locales/fr.json
@@ -144,7 +144,102 @@
"settingsSaved": "Paramètres du moteur de réservation enregistrés",
"settingsSaveFailed": "Échec de l’enregistrement des paramètres"
},
- "type": "Type"
+ "type": "Type",
+ "requestSettings": {
+ "autoConfirmDescription": "La confirmation automatique concerne uniquement les réservations instantanées ; les demandes exigent toujours une décision du personnel.",
+ "bookingMode": "Mode de réservation",
+ "backgroundLoadError": "Impossible de vérifier les réglages les plus récents. Les réglages chargés restent disponibles.",
+ "cardCollection": "Enregistrement de la carte",
+ "cardDescriptions": {
+ "disabled": "Les clients envoient leur demande sans ajouter de carte.",
+ "optional": "Les clients choisissent d’enregistrer ou non une carte pour les paiements initiés par le personnel.",
+ "required": "Les clients doivent enregistrer une carte avant l’envoi. Aucun débit n’est effectué automatiquement."
+ },
+ "cardPolicies": {
+ "disabled": "Désactivé",
+ "optional": "Facultatif",
+ "required": "Obligatoire"
+ },
+ "conflictDescription": "Ces réglages ont changé depuis l’ouverture de cette page. Votre brouillon est conservé. Rechargez les réglages les plus récents avant de reprendre les modifications.",
+ "conflictTitle": "Conflit de réglages",
+ "description": "Choisissez entre une réservation instantanée et une demande examinée par votre équipe.",
+ "engineToggle": "Activer le moteur de réservation directe",
+ "invalidQuestions": "Corrigez le formulaire client avant d’enregistrer ces paramètres.",
+ "loadError": "Impossible de charger les paramètres du moteur de réservation.",
+ "loading": "Chargement des paramètres du moteur de réservation",
+ "modeDescriptions": {
+ "instant": "Un paiement réussi suit le processus actuel de réservation instantanée.",
+ "request": "L’envoi crée une demande en attente sans confirmer de réservation."
+ },
+ "modes": {
+ "instant": "Réservation instantanée",
+ "request": "Demande de réservation"
+ },
+ "requiredCardWarning": "Ajoutez une clé publique Stripe avant d’exiger une carte.",
+ "unsupportedCardWarning": "Le prestataire de paiement configuré ne prend pas en charge les cartes enregistrées. Désactivez la collecte des cartes ou choisissez Stripe.",
+ "reloadLatest": "Recharger les réglages récents",
+ "reset": "Réinitialiser les modifications",
+ "retry": "Réessayer",
+ "save": "Enregistrer les modifications",
+ "saveError": "Impossible d’enregistrer les paramètres du moteur de réservation.",
+ "saving": "Enregistrement des modifications",
+ "stripeKey": "Clé publique Stripe",
+ "stripeKeyDescription": "Utilisez la clé publique de cet établissement. Les clés secrètes Stripe restent sur le serveur.",
+ "stripeKeyPlaceholder": "pk_live_…",
+ "title": "Processus de demande de réservation",
+ "unsaved": "Modifications non enregistrées",
+ "unsupportedPublishBlocked": "Utilisez une version plus récente du tableau de bord avant de publier des modifications de ce formulaire client."
+ },
+ "questions": {
+ "active": "Active",
+ "activeQuestion": "Question active",
+ "add": "Ajouter une question",
+ "addOption": "Ajouter une option",
+ "addTitle": "Ajouter une question",
+ "cancel": "Annuler",
+ "cancelEditor": "Fermer l’éditeur de question",
+ "count": "Questions : {{count}} / {{max}}",
+ "description": "Créez le formulaire ordonné que les clients remplissent avant d’envoyer une demande de réservation.",
+ "disable": "Désactiver {{label}}",
+ "duplicateIds": "Les identifiants des questions doivent être uniques pour modifier ce formulaire.",
+ "edit": "Modifier {{label}}",
+ "editTitle": "Modifier la question",
+ "emptyDescription": "Ajoutez uniquement les informations nécessaires à l’examen d’une demande.",
+ "emptyTitle": "Aucune question dans le formulaire",
+ "enable": "Activer {{label}}",
+ "idError": "Impossible de créer un identifiant unique. Réessayez.",
+ "inactive": "Inactive",
+ "label": "Libellé de la question",
+ "labelRequired": "Saisissez le libellé de la question.",
+ "moveDown": "Descendre {{label}}",
+ "moveOptionDown": "Descendre l’option {{number}}",
+ "moveOptionUp": "Monter l’option {{number}}",
+ "moveUp": "Monter {{label}}",
+ "optionBlank": "Les options ne peuvent pas être vides.",
+ "optionDuplicate": "Les options doivent être uniques.",
+ "optionLabel": "Option {{number}}",
+ "optionRequired": "Ajoutez au moins une option.",
+ "optional": "Facultative",
+ "options": "Options de réponse",
+ "remove": "Supprimer {{label}}",
+ "removeOption": "Supprimer l’option {{number}}",
+ "required": "Obligatoire",
+ "requiredQuestion": "Question obligatoire",
+ "save": "Enregistrer la question",
+ "title": "Plan du formulaire client",
+ "type": "Type de question",
+ "unsupportedActiveDescription": "Ce tableau de bord ne peut pas modifier ni republier ce formulaire client en toute sécurité tant que cette question reste active.",
+ "unsupportedActiveTitle": "Une question non prise en charge est active",
+ "unsupportedType": "Question non prise en charge",
+ "types": {
+ "short_text": "Texte court",
+ "long_text": "Texte long",
+ "single_select": "Sélection unique",
+ "multi_select": "Sélection multiple",
+ "yes_no": "Oui / non",
+ "date": "Date"
+ }
+ }
},
"cashier": {
"amount": "Montant",
@@ -1122,6 +1217,7 @@
"dashboard": "Tableau de bord",
"foliosBilling": "Folios et facturation",
"frontDesk": "Réception",
+ "bookingRequests": "Demandes de réservation",
"groups": "Groupes",
"guests": "Clients",
"houseAccounts": "Comptes internes",
@@ -1771,5 +1867,364 @@
"Bad Request": "Requête incorrecte",
"Resource not found": "Ressource introuvable",
"Action not permitted": "Action non autorisée"
+ },
+ "bookingRequests": {
+ "access": {
+ "title": "Accès restreint",
+ "description": "Vous avez besoin d'une autorisation pour lire les réservations avant de pouvoir consulter les demandes de réservation."
+ },
+ "property": {
+ "title": "Choisissez une propriété",
+ "description": "Les demandes de réservation sont examinées une propriété à la fois afin que les dossiers des clients, de l'argent et des décisions restent en toute sécurité."
+ },
+ "notFound": {
+ "title": "Demande introuvable",
+ "description": "Cette demande n'existe pas pour la propriété sélectionnée ou n'est plus disponible."
+ },
+ "common": {
+ "cancel": "Annuler",
+ "loading": "Chargement de la demande de réservation…",
+ "status": "Statut",
+ "yes": "Oui",
+ "no": "Non",
+ "notProvided": "Non fourni",
+ "retry": "Réessayer"
+ },
+ "actions": {
+ "accept": "Accepter la demande",
+ "deny": "Refuser la demande",
+ "charge": "Facturer la carte",
+ "record": "Enregistrer un paiement"
+ },
+ "statuses": {
+ "pending": "En attente",
+ "accepted": "Accepté",
+ "denied": "Refusé"
+ },
+ "tabs": {
+ "label": "Demander un espace de travail",
+ "overview": "Aperçu",
+ "payments": "Paiements et forfait",
+ "messages": "Messages",
+ "audit": "Vérification"
+ },
+ "queue": {
+ "title": "Demandes de réservation",
+ "description": "Examinez les candidatures des invités, les détails du séjour et l’état financier avant de prendre une action explicite.",
+ "filters": "Filtres de demande de réservation",
+ "guest": "Invité",
+ "card": "Carte",
+ "stay": "Rester",
+ "amount": "Montant demandé",
+ "allStatuses": "Tous les statuts",
+ "anyCard": "N'importe quel état de la carte",
+ "cardSaved": "Carte enregistrée",
+ "noCard": "Pas de carte",
+ "arrivalFrom": "Arrivée de",
+ "arrivalTo": "Arrivée à",
+ "sort": "Trier par",
+ "sortOptions": {
+ "newest": "Le plus récent en premier",
+ "arrival": "Date d'arrivée",
+ "guest": "Nom de l'invité",
+ "amountDesc": "Montant demandé le plus élevé"
+ },
+ "clear": "Effacer",
+ "loading": "Chargement des demandes de réservation…",
+ "loadError": "Les demandes de réservation n'ont pas pu être chargées. Essayer à nouveau.",
+ "empty": "Aucune demande de réservation ne correspond à ces filtres.",
+ "total_one": "{{count}} demande",
+ "total_other": "{{count}} requêtes",
+ "total": "{{count}} requêtes",
+ "previous": "Précédent",
+ "next": "Suivant"
+ },
+ "detail": {
+ "back": "Toutes les demandes de réservation",
+ "reference": "{{reference}} · Soumis {{date}}",
+ "decisionActions": "Décision",
+ "moneyActions": "Argent",
+ "decisionComplete": "Décision terminée",
+ "independence": "Les décisions de réservation et les actions financières sont indépendantes. L'acceptation du paiement n'accepte jamais une demande et l'acceptation ne déclenche jamais le paiement."
+ },
+ "amounts": {
+ "quoted": "Devis",
+ "submitted": "Soumis",
+ "current": "Actuel",
+ "accepted": "Accepté",
+ "difference": "Différence",
+ "captured": "Encaissé",
+ "returned": "Remboursé",
+ "retained": "Retenu"
+ },
+ "overview": {
+ "stay": "Demande de séjour",
+ "dates": "Dates",
+ "occupancy": "Occupation",
+ "occupancyValue": "{{adults}} adultes · {{children}} enfants",
+ "roomType": "Type de chambre",
+ "ratePlan": "Plan tarifaire",
+ "guest": "Invité et contact",
+ "email": "Courriel",
+ "phone": "Téléphone",
+ "specialRequests": "Demandes spéciales",
+ "application": "Demande",
+ "noQuestions": "Aucune question de candidature supplémentaire n’a été soumise.",
+ "priceComparison": "Comparaison des prix",
+ "card": "Carte au dossier",
+ "cardGeneric": "carte",
+ "noCard": "Aucune carte enregistrée",
+ "cardSafety": "Seuls le réseau de la carte et les quatre derniers chiffres sont affichés. Le personnel doit déclencher explicitement chaque débit.",
+ "decision": "Dossier de décision",
+ "priceSource": "Source de prix"
+ },
+ "priceSources": {
+ "submitted": "Devis soumis",
+ "current": "Devis actuel",
+ "custom": "Total personnalisé"
+ },
+ "accept": {
+ "title": "Accepter la demande de réservation",
+ "priceChoice": "Prix accepté",
+ "submitted": "Devis soumis",
+ "submittedDescription": "Utilisez le montant que l'invité a vu lors de l'envoi de la demande.",
+ "current": "Devis actuel",
+ "currentDescription": "Revérifiez la disponibilité et calculez le devis actuel faisant autorité lors de l'acceptation.",
+ "custom": "Total personnalisé",
+ "customDescription": "Fixez un total explicite et notez pourquoi il diffère.",
+ "recheckedOnAccept": "Revérifié à l'acceptation",
+ "enterAmount": "Entrez un montant",
+ "customTotal": "Total personnalisé",
+ "customReason": "Raison du total personnalisé",
+ "independence": "L'acceptation crée la réservation. Il ne débite pas la carte enregistrée et ne dépend pas de l'état du paiement.",
+ "accepting": "Accepter…",
+ "error": "La demande n'a pas pu être acceptée. Vérifiez la disponibilité actuelle et réessayez."
+ },
+ "modifyStay": {
+ "action": "Modifier le séjour", "title": "Modifier le séjour accepté", "activeStay": "Séjour actif", "proposedStay": "Séjour proposé", "originalRequest": "Demande d'origine",
+ "arrivalDate": "Date d'arrivée", "departureDate": "Date de départ", "invalidDates": "Le départ doit suivre l'arrivée et les deux dates doivent être valides.",
+ "checking": "Vérification du séjour complet et du tarif actuel…", "previewError": "Ce séjour ne peut pas être chiffré maintenant. Vérifiez la disponibilité et réessayez.",
+ "priceChoice": "Base tarifaire du séjour modifié", "prior": "Base acceptée précédente", "priorDescription": "Les dates communes conservent leurs lignes acceptées ; les nuits ajoutées reprennent le tarif accepté de limite le plus proche.",
+ "current": "Devis actuel", "currentDescription": "Utilisez les tarifs de référence disponibles maintenant pour l'ensemble du séjour proposé.", "custom": "Total personnalisé", "customDescription": "Définissez un total opérationnel positif et consignez la raison.",
+ "awaitingQuote": "Devis en attente", "enterAmount": "Saisissez un montant", "customTotal": "Total personnalisé", "customReason": "Raison du total personnalisé",
+ "apply": "Appliquer la modification", "applying": "Application…", "commitError": "Le séjour n'a pas pu être modifié. Vérifiez la disponibilité et le devis les plus récents."
+ },
+ "deny": {
+ "title": "Refuser la demande de réservation",
+ "reason": "Raison du refus",
+ "unresolved": "{{amount}} reste non résolu",
+ "unresolvedDirection": "Remboursez un paiement par carte enregistré, enregistrez un retour externe ou conservez le montant avec un motif avant de refuser.",
+ "resolveFirst": "Réglez d'abord l'argent",
+ "confirm": "Confirmer le refus",
+ "denying": "Refus en cours…",
+ "error": "La demande ne pouvait être refusée. Vérifiez l'état de son argent et réessayez.",
+ "moneyLoading": "Vérification de l’état des paiements avant le refus…",
+ "moneyLoadError": "L’état des paiements n’a pas pu être vérifié. Le refus reste bloqué.",
+ "retryMoney": "Revérifier les paiements"
+ },
+ "validation": {
+ "positiveAmount": "Entrez un montant supérieur à zéro.",
+ "required": "Saisissez un montant.",
+ "format": "Saisissez un montant décimal valide.",
+ "positive": "Saisissez un montant supérieur à zéro.",
+ "precision": "Utilisez uniquement les décimales autorisées pour cette devise.",
+ "unsupportedCurrency": "Les décimales de cette devise ne sont pas prises en charge par le registre.",
+ "maximum": "Saisissez un montant inférieur ou égal au maximum autorisé."
+ },
+ "payments": {
+ "requestSummary": "Récapitulatif des paiements de la demande",
+ "independence": "Les décisions de réservation ne dépendent pas de l'état du paiement.",
+ "plan": "Plan de paiement",
+ "noAutomatic": "Rien n'est facturé automatiquement. Les jalons sont des rappels des actions initiées par le personnel.",
+ "noAutomaticTitle": "Rien n'est facturé automatiquement.",
+ "noAutomaticDescription": "Les jalons sont des rappels des actions initiées par le personnel.",
+ "addInstallment": "Ajouter un versement",
+ "editInstallment": "Modifier le versement",
+ "installmentLabel": "Étiquette de versement",
+ "amountType": "Type de montant",
+ "fixedAmount": "Montant fixe",
+ "percentage": "Pourcentage",
+ "milestone": "Jalon dû",
+ "dueDate": "Date d'échéance",
+ "saveInstallment": "Enregistrer le versement",
+ "installmentError": "Le versement n'a pas pu être enregistré.",
+ "noInstallments": "Aucun versement de plan de paiement pour l'instant.",
+ "allocated": "{{allocated}} sur {{total}} attribués",
+ "editLabel": "Modifier {{label}}",
+ "deleteLabel": "Supprimer {{label}}",
+ "removeRemainingAmount": "Supprimer le montant restant — {{amount}} restera payé",
+ "allocateLabel": "Attribuer le paiement au {{label}}",
+ "allocateTo": "Attribuer le paiement au {{label}}",
+ "movement": "Paiement encaissé",
+ "allocate": "Allouer",
+ "allocationError": "Le paiement n'a pas pu être réparti.",
+ "availableForAllocation": "{{available}} disponible · {{allocated}} alloué · {{method}}",
+ "allocationAvailability": "{{available}} disponible · {{allocated}} alloué",
+ "moveUp": "Monter {{label}}",
+ "moveDown": "Descendre {{label}}",
+ "reorderError": "L’ordre des échéances n’a pas pu être enregistré. Réessayez.",
+ "movements": "Mouvements de paiement",
+ "noMovements": "Aucun mouvement de paiement pour l'instant.",
+ "savedCardProvenance": "Carte enregistrée · {{brand}} •••• {{lastFour}}",
+ "externalProvenance": "Externe · {{method}} · {{reference}}",
+ "loadError": "Le plan de paiement et l'historique des mouvements n'ont pas pu être chargés.",
+ "folioSummary": "Résumé du folio opérationnel",
+ "acceptedDeal": "Total accepté",
+ "activeStayTotal": "Total du séjour actif",
+ "folioCharges": "Frais de folio",
+ "folioPayments": "Paiements folio",
+ "balanceDue": "Solde dû",
+ "folioError": "Le résumé du folio lié n'a pas pu être chargé."
+ },
+ "installmentStatuses": {
+ "unpaid": "Impayé",
+ "partial": "Partiel",
+ "paid": "Payé"
+ },
+ "milestones": {
+ "manual": "Suivi manuel",
+ "arrival": "À payer à l'arrivée",
+ "checkout": "À payer à la caisse",
+ "date": "Date précise",
+ "dateValue": "À payer {{date}}"
+ },
+ "methods": {
+ "credit_card": "carte de crédit",
+ "debit_card": "carte de débit",
+ "cash": "en espèces",
+ "bank_transfer": "virement bancaire",
+ "pix": "PIX",
+ "other": "autre"
+ },
+ "paymentStatuses": {
+ "pending": "En attente",
+ "authorized": "Autorisé",
+ "captured": "Encaissé",
+ "settled": "Comptabilisé",
+ "refunded": "Remboursé",
+ "partially_refunded": "Partiellement remboursé",
+ "failed": "Échec",
+ "voided": "Annulé"
+ },
+ "paymentActions": {
+ "amount": "Montant",
+ "method": "Mode de paiement",
+ "processedAt": "Traité à",
+ "provider": "Fournisseur (facultatif)",
+ "reference": "Référence",
+ "notes": "Remarques (facultatif)",
+ "saving": "Sauvegarde…",
+ "error": "L’action financière n’a pas pu être menée à son terme. Vos informations saisies sont toujours ici.",
+ "paymentRequired": "Choisissez d'abord un mouvement de paiement.",
+ "charge": {
+ "title": "Débiter la carte enregistrée",
+ "action": "Débiter la carte enregistrée",
+ "description": "Ce débit est déclenché explicitement par le personnel. Il n’accepte pas la demande."
+ },
+ "external": {
+ "title": "Enregistrer le paiement externe",
+ "action": "Enregistrer le paiement externe",
+ "description": "Enregistrez l'argent déjà collecté en dehors de la passerelle de carte enregistrée."
+ },
+ "refund": {
+ "title": "Rembourser le paiement par carte enregistrée",
+ "action": "Remboursement",
+ "description": "Renvoyez une partie ou la totalité de ce paiement de passerelle."
+ },
+ "external_return": {
+ "title": "Enregistrer le remboursement externe",
+ "action": "Enregistrer le remboursement",
+ "description": "Enregistrez que l’argent collecté en externe a été restitué."
+ },
+ "retain": {
+ "title": "Retenir le montant",
+ "action": "Retenir le montant",
+ "open": "Retenir avec un motif",
+ "description": "Résolvez ce montant comme retenu avant le refus. Un motif professionnel est obligatoire.",
+ "reason": "Raison de la retenue d'argent"
+ }
+ },
+ "messages": {
+ "title": "Livraisons de messages",
+ "description": "Le courrier électronique transactionnel est une conséquence des actions du personnel. Un échec de livraison n’annule jamais une décision ou un mouvement d’argent.",
+ "loading": "Chargement de l'historique des messages…",
+ "loadError": "L'historique des messages n'a pas pu être chargé.",
+ "empty": "Aucun message transactionnel n'a encore été enregistré.",
+ "retry": "Réessayer la livraison",
+ "retryError": "La livraison ayant échoué n'a pas pu être réessayée.",
+ "attempts_one": "{{count}} tentative",
+ "attempts_other": "{{count}} tentatives",
+ "attempts": "{{count}} tentatives",
+ "actorNote": "Une nouvelle tentative manuelle est enregistrée avec l'identité de votre personnel."
+ },
+ "messageKinds": {
+ "receipt": "Demander un reçu",
+ "accepted": "Acceptation",
+ "denied": "Refus",
+ "payment": "Paiement",
+ "refund": "Remboursement",
+ "failure": "Échec de paiement"
+ },
+ "messageStatuses": {
+ "pending": "En attente",
+ "processing": "Traitement",
+ "sent": "Envoyé",
+ "failed": "Échec"
+ },
+ "audit": {
+ "title": "Chronologie des affaires",
+ "description": "Une vue opérationnelle sécurisée dérivée des enregistrements de demandes, de mouvements, de résolutions et de livraisons disponibles pour le personnel.",
+ "submitted": "Demande soumise",
+ "submittedDescription": "La candidature invitée et l'instantané du devis soumis ont été enregistrés.",
+ "accepted": "Demande acceptée",
+ "acceptedDescription": "Accepté au {{amount}} en utilisant le {{source}}.",
+ "denied": "Demande refusée",
+ "deniedDescription": "La demande a été refusée après que son état monétaire ait été résolu.",
+ "paymentCaptured": "Paiement encaissé",
+ "paymentFailed": "Échec du paiement",
+ "paymentReturned": "Paiement retourné",
+ "paymentDescription": "{{amount}} · {{method}}",
+ "resolutionDescription": "{{amount}} résolu",
+ "resolutions": {
+ "refund": "Remboursement par carte enregistré",
+ "external_return": "Remboursement externe enregistré",
+ "retained": "Argent retenu"
+ },
+ "message": "{{kind}} message",
+ "messageDescription": "Statut de livraison : {{status}}",
+ "actor": "Effectué par : {{actor}}",
+ "loadError": "L’historique d’audit n’a pas pu être chargé.",
+ "empty": "Aucun événement d’audit n’a été enregistré.",
+ "loadMore": "Charger plus",
+ "loadingMore": "Chargement d’autres événements…",
+ "loadMoreError": "Les autres événements d’audit n’ont pas pu être chargés.",
+ "events": {
+ "request_pending": "Demande envoyée",
+ "request_accepted": "Demande acceptée",
+ "request_denied": "Demande refusée",
+ "request_updated": "Demande mise à jour",
+ "installment_created": "Échéance créée",
+ "installment_updated": "Échéance mise à jour",
+ "installment_deleted": "Échéance supprimée",
+ "allocation_recorded": "Paiement alloué",
+ "allocation_removed": "Allocation du paiement supprimée",
+ "payment_pending": "Paiement en attente",
+ "payment_captured": "Paiement encaissé",
+ "payment_failed": "Échec du paiement",
+ "payment_recorded": "Paiement enregistré",
+ "payment_updated": "Paiement mis à jour",
+ "resolution_refund": "Remboursement enregistré",
+ "resolution_external_return": "Retour externe enregistré",
+ "resolution_retained": "Somme conservée",
+ "resolution_recorded": "Résolution du paiement enregistrée",
+ "email_pending": "E-mail mis en file d’attente",
+ "email_processing": "Tentative d’envoi de l’e-mail",
+ "email_sent": "E-mail envoyé",
+ "email_failed": "Échec de l’envoi de l’e-mail",
+ "email_queued": "E-mail mis en file d’attente",
+ "email_updated": "Envoi de l’e-mail mis à jour",
+ "stay_amended": "Séjour accepté modifié"
+ }
+ }
}
}
diff --git a/apps/dashboard/src/locales/hr.json b/apps/dashboard/src/locales/hr.json
index f1156561..cb710072 100644
--- a/apps/dashboard/src/locales/hr.json
+++ b/apps/dashboard/src/locales/hr.json
@@ -144,7 +144,102 @@
"settingsSaved": "Postavke sustava za rezervacije su spremljene",
"settingsSaveFailed": "Spremanje postavki nije uspjelo"
},
- "type": "Tip"
+ "type": "Tip",
+ "requestSettings": {
+ "autoConfirmDescription": "Automatska potvrda odnosi se samo na trenutne rezervacije; zahtjevi uvijek trebaju odluku osoblja.",
+ "bookingMode": "Način rezervacije",
+ "backgroundLoadError": "Najnovije postavke nije bilo moguće provjeriti. Učitane postavke i dalje su dostupne.",
+ "cardCollection": "Prikupljanje kartice",
+ "cardDescriptions": {
+ "disabled": "Gosti šalju zahtjev bez dodavanja kartice.",
+ "optional": "Gosti biraju žele li spremiti karticu za plaćanja koja pokreće osoblje.",
+ "required": "Gosti moraju spremiti karticu prije slanja zahtjeva. Nema automatske naplate."
+ },
+ "cardPolicies": {
+ "disabled": "Onemogućeno",
+ "optional": "Neobavezno",
+ "required": "Obavezno"
+ },
+ "conflictDescription": "Ove su se postavke promijenile otkako ste otvorili stranicu. Vaš je nacrt sačuvan. Ponovno učitajte najnovije postavke prije nastavka uređivanja.",
+ "conflictTitle": "Sukob postavki",
+ "description": "Odaberite rezerviraju li gosti odmah ili šalju prijavu na pregled vašem timu.",
+ "engineToggle": "Omogući sustav za izravne rezervacije",
+ "invalidQuestions": "Ispravite obrazac za goste prije spremanja ovih postavki.",
+ "loadError": "Nije moguće učitati postavke sustava za rezervacije.",
+ "loading": "Učitavanje postavki sustava za rezervacije",
+ "modeDescriptions": {
+ "instant": "Uspješna naplata slijedi postojeći tijek trenutne rezervacije.",
+ "request": "Slanje stvara zahtjev na čekanju bez potvrde rezervacije."
+ },
+ "modes": {
+ "instant": "Trenutna rezervacija",
+ "request": "Zahtjev za rezervaciju"
+ },
+ "requiredCardWarning": "Dodajte Stripeov javni ključ prije obaveznog prikupljanja kartice.",
+ "unsupportedCardWarning": "Konfigurirani pružatelj plaćanja ne podržava spremljene kartice. Isključite prikupljanje kartica ili odaberite Stripe.",
+ "reloadLatest": "Ponovno učitaj najnovije postavke",
+ "reset": "Poništi promjene",
+ "retry": "Pokušaj ponovno",
+ "save": "Spremi promjene",
+ "saveError": "Nije moguće spremiti postavke sustava za rezervacije.",
+ "saving": "Spremanje promjena",
+ "stripeKey": "Stripeov javni ključ",
+ "stripeKeyDescription": "Upotrijebite javni ključ ovog objekta. Tajni Stripeovi ključevi ostaju na poslužitelju.",
+ "stripeKeyPlaceholder": "pk_live_…",
+ "title": "Tijek zahtjeva za rezervaciju",
+ "unsaved": "Nespremljene promjene",
+ "unsupportedPublishBlocked": "Upotrijebite noviju verziju nadzorne ploče prije objave promjena ovog obrasca za goste."
+ },
+ "questions": {
+ "active": "Aktivno",
+ "activeQuestion": "Aktivno pitanje",
+ "add": "Dodaj pitanje",
+ "addOption": "Dodaj mogućnost",
+ "addTitle": "Dodaj pitanje",
+ "cancel": "Odustani",
+ "cancelEditor": "Zatvori uređivač pitanja",
+ "count": "Pitanja: {{count}} / {{max}}",
+ "description": "Sastavite redoslijed prijave koju gosti ispunjavaju prije slanja zahtjeva za rezervaciju.",
+ "disable": "Onemogući {{label}}",
+ "duplicateIds": "Identifikatori pitanja moraju biti jedinstveni kako bi se obrazac mogao uređivati.",
+ "edit": "Uredi {{label}}",
+ "editTitle": "Uredi pitanje",
+ "emptyDescription": "Dodajte samo podatke potrebne vašem timu za pregled zahtjeva.",
+ "emptyTitle": "Još nema pitanja u prijavi",
+ "enable": "Omogući {{label}}",
+ "idError": "Nije moguće izraditi jedinstveni identifikator. Pokušajte ponovno.",
+ "inactive": "Neaktivno",
+ "label": "Tekst pitanja",
+ "labelRequired": "Unesite tekst pitanja.",
+ "moveDown": "Pomakni {{label}} dolje",
+ "moveOptionDown": "Pomakni mogućnost {{number}} dolje",
+ "moveOptionUp": "Pomakni mogućnost {{number}} gore",
+ "moveUp": "Pomakni {{label}} gore",
+ "optionBlank": "Mogućnosti ne smiju biti prazne.",
+ "optionDuplicate": "Mogućnosti moraju biti jedinstvene.",
+ "optionLabel": "Mogućnost {{number}}",
+ "optionRequired": "Dodajte barem jednu mogućnost.",
+ "optional": "Neobavezno",
+ "options": "Mogućnosti odgovora",
+ "remove": "Ukloni {{label}}",
+ "removeOption": "Ukloni mogućnost {{number}}",
+ "required": "Obavezno",
+ "requiredQuestion": "Obavezno pitanje",
+ "save": "Spremi pitanje",
+ "title": "Plan obrasca za goste",
+ "type": "Vrsta pitanja",
+ "unsupportedActiveDescription": "Ova nadzorna ploča ne može sigurno promijeniti ni ponovno objaviti obrazac za goste dok je to pitanje aktivno.",
+ "unsupportedActiveTitle": "Aktivno je nepodržano pitanje",
+ "unsupportedType": "Nepodržano pitanje",
+ "types": {
+ "short_text": "Kratki tekst",
+ "long_text": "Dugi tekst",
+ "single_select": "Jedan odabir",
+ "multi_select": "Višestruki odabir",
+ "yes_no": "Da / ne",
+ "date": "Datum"
+ }
+ }
},
"cashier": {
"amount": "Iznos",
@@ -1122,6 +1217,7 @@
"dashboard": "Nadzorna ploča",
"foliosBilling": "Foliji i naplata",
"frontDesk": "Recepcija",
+ "bookingRequests": "Zahtjevi za rezervaciju",
"groups": "Grupe",
"guests": "Gosti",
"houseAccounts": "Interni računi",
@@ -1771,5 +1867,364 @@
"Bad Request": "Neispravan zahtjev",
"Resource not found": "Resurs nije pronađen",
"Action not permitted": "Radnja nije dopuštena"
+ },
+ "bookingRequests": {
+ "access": {
+ "title": "Pristup ograničen",
+ "description": "Potrebno vam je dopuštenje za čitanje rezervacija prije nego što možete pregledati zahtjeve za rezervacije."
+ },
+ "property": {
+ "title": "Odaberite jednu nekretninu",
+ "description": "Zahtjevi za rezervaciju pregledavaju se jedan po jedan objekt tako da gosti, novac i zapisi o odlukama ostaju na sigurnom."
+ },
+ "notFound": {
+ "title": "Zahtjev nije pronađen",
+ "description": "Ovaj zahtjev ne postoji na odabranom objektu ili više nije dostupan."
+ },
+ "common": {
+ "cancel": "Odustani",
+ "loading": "Učitavanje zahtjeva za rezervaciju…",
+ "status": "Status",
+ "yes": "Da",
+ "no": "Ne",
+ "notProvided": "Nije navedeno",
+ "retry": "Pokušaj ponovno"
+ },
+ "actions": {
+ "accept": "Prihvati zahtjev",
+ "deny": "Odbij zahtjev",
+ "charge": "Naplati karticu",
+ "record": "Evidentiraj uplatu"
+ },
+ "statuses": {
+ "pending": "Na čekanju",
+ "accepted": "Prihvaćeno",
+ "denied": "Odbijeno"
+ },
+ "tabs": {
+ "label": "Radni prostor zahtjeva",
+ "overview": "Pregled",
+ "payments": "Plaćanja i plan",
+ "messages": "Poruke",
+ "audit": "Revizija"
+ },
+ "queue": {
+ "title": "Zahtjevi za rezervaciju",
+ "description": "Pregledajte zahtjeve gostiju, podatke o boravku i stanje plaćanja prije poduzimanja izričite radnje.",
+ "filters": "Filtri zahtjeva za rezervaciju",
+ "guest": "Gost",
+ "card": "Kartica",
+ "stay": "Boravak",
+ "amount": "Traženi iznos",
+ "allStatuses": "Svi statusi",
+ "anyCard": "Bilo koje stanje kartice",
+ "cardSaved": "Kartica je spremljena",
+ "noCard": "Nema kartice",
+ "arrivalFrom": "Dolazak iz",
+ "arrivalTo": "Dolazak u",
+ "sort": "Poredaj po",
+ "sortOptions": {
+ "newest": "Prvo najnoviji",
+ "arrival": "Datum dolaska",
+ "guest": "Ime gosta",
+ "amountDesc": "Najviši zatraženi iznos"
+ },
+ "clear": "Očisti",
+ "loading": "Učitavanje zahtjeva za rezervaciju…",
+ "loadError": "Zahtjevi za rezervaciju nisu se mogli učitati. Pokušajte ponovno.",
+ "empty": "Nijedan zahtjev za rezervaciju ne odgovara ovim filtrima.",
+ "total_one": "{{count}} zahtjev",
+ "total_other": "{{count}} zahtjeva",
+ "total": "{{count}} zahtjeva",
+ "previous": "Prethodno",
+ "next": "Dalje"
+ },
+ "detail": {
+ "back": "Svi zahtjevi za rezervacije",
+ "reference": "{{reference}} · Poslano {{date}}",
+ "decisionActions": "Odluka",
+ "moneyActions": "Financije",
+ "decisionComplete": "Odluka dovršena",
+ "independence": "Odluke o rezervaciji i financijske radnje neovisne su. Prihvaćanje plaćanja nikada ne prihvaća zahtjev, a prihvaćanje nikada ne pokreće plaćanje."
+ },
+ "amounts": {
+ "quoted": "Ponuda",
+ "submitted": "Poslano",
+ "current": "Trenutni",
+ "accepted": "Prihvaćeno",
+ "difference": "Razlika",
+ "captured": "Naplaćeno",
+ "returned": "Vraćeno",
+ "retained": "Zadržano"
+ },
+ "overview": {
+ "stay": "Zahtjev za ostanak",
+ "dates": "Datumi",
+ "occupancy": "Popunjenost",
+ "occupancyValue": "{{adults}} odrasli · {{children}} djeca",
+ "roomType": "Tip sobe",
+ "ratePlan": "Tarifni plan",
+ "guest": "Gost & kontakt",
+ "email": "E-mail",
+ "phone": "Telefon",
+ "specialRequests": "Posebni zahtjevi",
+ "application": "Zahtjev",
+ "noQuestions": "Nisu poslani dodatni odgovori uz zahtjev.",
+ "priceComparison": "Usporedba cijena",
+ "card": "Kartica u dosjeu",
+ "cardGeneric": "kartica",
+ "noCard": "Nema spremljene kartice",
+ "cardSafety": "Prikazuju se samo marka kartice i posljednje četiri znamenke. Osoblje mora izričito pokrenuti svaku naplatu.",
+ "decision": "Zapisnik odluke",
+ "priceSource": "Izvor cijene"
+ },
+ "priceSources": {
+ "submitted": "Poslana ponuda",
+ "current": "Trenutna ponuda",
+ "custom": "Prilagođeni ukupni iznos"
+ },
+ "accept": {
+ "title": "Prihvatite zahtjev za rezervaciju",
+ "priceChoice": "Prihvaćena cijena",
+ "submitted": "Poslana ponuda",
+ "submittedDescription": "Koristite iznos koji je gost vidio kada je zahtjev poslan.",
+ "current": "Trenutna ponuda",
+ "currentDescription": "Ponovno provjerite dostupnost i izračunajte mjerodavnu trenutnu ponudu tijekom prihvaćanja.",
+ "custom": "Prilagođeni ukupni iznos",
+ "customDescription": "Postavite eksplicitni ukupni iznos i zabilježite zašto se razlikuje.",
+ "recheckedOnAccept": "Ponovno provjereno pri prihvaćanju",
+ "enterAmount": "Unesite iznos",
+ "customTotal": "Prilagođeni ukupni iznos",
+ "customReason": "Razlog prilagođenog ukupnog iznosa",
+ "independence": "Prihvaćanje stvara rezervaciju. Ne tereti spremljenu karticu niti ovisi o stanju plaćanja.",
+ "accepting": "Prihvaćanje...",
+ "error": "Zahtjev nije mogao biti prihvaćen. Pregledajte trenutnu dostupnost i pokušajte ponovno."
+ },
+ "modifyStay": {
+ "action": "Izmijeni boravak", "title": "Izmijeni prihvaćeni boravak", "activeStay": "Aktivni boravak", "proposedStay": "Predloženi boravak", "originalRequest": "Izvorni zahtjev",
+ "arrivalDate": "Datum dolaska", "departureDate": "Datum odlaska", "invalidDates": "Odlazak mora biti nakon dolaska, a oba datuma moraju biti valjana.",
+ "checking": "Provjera cijelog boravka i trenutne ponude…", "previewError": "Ovaj boravak trenutačno nije moguće izračunati. Provjerite dostupnost i pokušajte ponovno.",
+ "priceChoice": "Osnova cijene za izmijenjeni boravak", "prior": "Prethodna prihvaćena osnova", "priorDescription": "Datumi koji se preklapaju zadržavaju prihvaćene stavke; dodane noći koriste najbližu rubnu prihvaćenu cijenu.",
+ "current": "Trenutna ponuda", "currentDescription": "Koristite mjerodavne cijene koje su sada dostupne za cijeli predloženi boravak.", "custom": "Prilagođeni ukupni iznos", "customDescription": "Postavite pozitivan operativni ukupni iznos i zabilježite razlog.",
+ "awaitingQuote": "Ponuda se čeka", "enterAmount": "Unesite iznos", "customTotal": "Prilagođeni ukupni iznos", "customReason": "Razlog prilagođenog ukupnog iznosa",
+ "apply": "Primijeni promjenu boravka", "applying": "Primjenjuje se…", "commitError": "Boravak nije moguće promijeniti. Pregledajte najnoviju dostupnost i ponudu."
+ },
+ "deny": {
+ "title": "Odbij zahtjev za rezervaciju",
+ "reason": "Razlog odbijanja",
+ "unresolved": "{{amount}} ostaje neriješeno",
+ "unresolvedDirection": "Vratite novac za plaćanje spremljenom karticom, zabilježite vanjski povrat ili zadržite iznos s razlogom prije odbijanja.",
+ "resolveFirst": "Prvo riješite novac",
+ "confirm": "Potvrdite odbijanje",
+ "denying": "Odbijanje…",
+ "error": "Zahtjev nije mogao biti odbijen. Pregledajte stanje novca i pokušajte ponovno.",
+ "moneyLoading": "Provjera stanja plaćanja prije odbijanja…",
+ "moneyLoadError": "Stanje plaćanja nije moguće provjeriti. Odbijanje ostaje blokirano.",
+ "retryMoney": "Ponovno provjeri stanje plaćanja"
+ },
+ "validation": {
+ "positiveAmount": "Unesite iznos veći od nule.",
+ "required": "Unesite iznos.",
+ "format": "Unesite valjani decimalni iznos.",
+ "positive": "Unesite iznos veći od nule.",
+ "precision": "Upotrijebite samo decimalna mjesta koja podržava ova valuta.",
+ "unsupportedCurrency": "Glavna knjiga ne podržava decimalna mjesta ove valute.",
+ "maximum": "Unesite iznos unutar dopuštenog maksimuma."
+ },
+ "payments": {
+ "requestSummary": "Sažetak plaćanja za zahtjev",
+ "independence": "Odluke o rezervaciji ne ovise o stanju plaćanja.",
+ "plan": "Plan plaćanja",
+ "noAutomatic": "Ništa se ne naplaćuje automatski. Prekretnice su podsjetnici za radnje koje pokreće osoblje.",
+ "noAutomaticTitle": "Ništa se ne naplaćuje automatski.",
+ "noAutomaticDescription": "Prekretnice su podsjetnici za radnje koje pokreće osoblje.",
+ "addInstallment": "Dodaj ratu",
+ "editInstallment": "Uredi ratu",
+ "installmentLabel": "Oznaka rate",
+ "amountType": "Vrsta iznosa",
+ "fixedAmount": "Fiksni iznos",
+ "percentage": "Postotak",
+ "milestone": "Dospjela prekretnica",
+ "dueDate": "Datum dospijeća",
+ "saveInstallment": "Spremi ratu",
+ "installmentError": "Rata se nije mogla spremiti.",
+ "noInstallments": "Još nema rata plaćanja.",
+ "allocated": "{{allocated}} od {{total}} dodijeljeno",
+ "editLabel": "Uredi {{label}}",
+ "deleteLabel": "Izbriši {{label}}",
+ "removeRemainingAmount": "Ukloni preostali iznos — {{amount}} ostaje plaćeno",
+ "allocateLabel": "Dodijelite uplatu na {{label}}",
+ "allocateTo": "Dodijelite uplatu na {{label}}",
+ "movement": "Naplaćena uplata",
+ "allocate": "Dodijeliti",
+ "allocationError": "Plaćanje nije bilo moguće dodijeliti.",
+ "availableForAllocation": "{{available}} dostupno · {{allocated}} dodijeljeno · {{method}}",
+ "allocationAvailability": "{{available}} dostupno · {{allocated}} dodijeljeno",
+ "moveUp": "Pomakni {{label}} gore",
+ "moveDown": "Pomakni {{label}} dolje",
+ "reorderError": "Redoslijed obroka nije moguće spremiti. Pokušajte ponovno.",
+ "movements": "Prometi plaćanja",
+ "noMovements": "Još nema prometa plaćanja.",
+ "savedCardProvenance": "Spremljena kartica · {{brand}} •••• {{lastFour}}",
+ "externalProvenance": "Vanjski · {{method}} · {{reference}}",
+ "loadError": "Plan plaćanja i povijest kretanja nisu se mogli učitati.",
+ "folioSummary": "Sažetak operativnog folija",
+ "acceptedDeal": "Prihvaćen dogovor",
+ "activeStayTotal": "Ukupno za aktivni boravak",
+ "folioCharges": "Stavke folija",
+ "folioPayments": "Folio plaćanja",
+ "balanceDue": "Saldo duga",
+ "folioError": "Povezani sažetak folija nije se mogao učitati."
+ },
+ "installmentStatuses": {
+ "unpaid": "Neplaćeno",
+ "partial": "Djelomično",
+ "paid": "Plaćeno"
+ },
+ "milestones": {
+ "manual": "Ručno praćenje",
+ "arrival": "Plaća se po dolasku",
+ "checkout": "Dospjeva na blagajni",
+ "date": "Određeni datum",
+ "dateValue": "Rok {{date}}"
+ },
+ "methods": {
+ "credit_card": "kreditna kartica",
+ "debit_card": "debitna kartica",
+ "cash": "gotovina",
+ "bank_transfer": "bankovni transfer",
+ "pix": "PIX",
+ "other": "drugo"
+ },
+ "paymentStatuses": {
+ "pending": "Na čekanju",
+ "authorized": "Ovlašten",
+ "captured": "Naplaćeno",
+ "settled": "Knjiženo",
+ "refunded": "Vraćeno",
+ "partially_refunded": "Djelomično vraćeno",
+ "failed": "nije uspjelo",
+ "voided": "Poništen"
+ },
+ "paymentActions": {
+ "amount": "Iznos",
+ "method": "Način plaćanja",
+ "processedAt": "Obrađeno u",
+ "provider": "Davatelj (neobavezno)",
+ "reference": "Referenca",
+ "notes": "Bilješke (nije obavezno)",
+ "saving": "Spremanje...",
+ "error": "Financijska radnja nije mogla biti dovršena. Uneseni podaci ostaju sačuvani.",
+ "paymentRequired": "Prvo odaberite način plaćanja.",
+ "charge": {
+ "title": "Naplati spremljenu karticu",
+ "action": "Naplati spremljenu karticu",
+ "description": "Ovo je eksplicitna naknada koju pokreće osoblje. Ne prihvaća zahtjev."
+ },
+ "external": {
+ "title": "Zabilježite vanjsko plaćanje",
+ "action": "Zabilježite vanjsko plaćanje",
+ "description": "Zabilježite novac koji je već prikupljen izvan pristupnika spremljene kartice."
+ },
+ "refund": {
+ "title": "Povrat plaćanja spremljenom karticom",
+ "action": "Povrat novca",
+ "description": "Vratite dio ili cijelo ovo gateway plaćanje."
+ },
+ "external_return": {
+ "title": "Zabilježite vanjski povrat",
+ "action": "Evidentiraj povrat",
+ "description": "Zabilježite da je novac prikupljen izvana vraćen."
+ },
+ "retain": {
+ "title": "Zadrži novac",
+ "action": "Zadrži novac",
+ "open": "Zadržati s razlogom",
+ "description": "Riješite ovaj iznos kao zadržan prije odbijanja. Poslovni razlog je obavezan.",
+ "reason": "Razlog zadržavanja novca"
+ }
+ },
+ "messages": {
+ "title": "Isporuke poruka",
+ "description": "Transakcijski e-mail je posljedica radnji osoblja. Neuspjeh u isporuci nikada ne poništava odluku ili kretanje novca.",
+ "loading": "Učitavanje povijesti poruka…",
+ "loadError": "Povijest poruka nije se mogla učitati.",
+ "empty": "Još nije zabilježena nijedna transakcijska poruka.",
+ "retry": "Ponovi isporuku",
+ "retryError": "Neuspjela isporuka nije mogla biti ponovljena.",
+ "attempts_one": "{{count}} pokušaj",
+ "attempts_other": "{{count}} pokušaja",
+ "attempts": "{{count}} pokušaja",
+ "actorNote": "Ručni ponovni pokušaj bilježi se s vašim identitetom osoblja."
+ },
+ "messageKinds": {
+ "receipt": "Zatražite potvrdu",
+ "accepted": "prihvaćanje",
+ "denied": "Odbijanje",
+ "payment": "Plaćanje",
+ "refund": "Povrat novca",
+ "failure": "Neuspjeh plaćanja"
+ },
+ "messageStatuses": {
+ "pending": "Na čekanju",
+ "processing": "Obrada",
+ "sent": "Poslano",
+ "failed": "nije uspjelo"
+ },
+ "audit": {
+ "title": "Poslovna vremenska linija",
+ "description": "Siguran radni pregled izveden iz zapisa o zahtjevima, kretanju, rješavanju i isporuci dostupnih osoblju.",
+ "submitted": "Zahtjev poslan",
+ "submittedDescription": "Snimljeni su zahtjev gosta i dostavljena ponuda.",
+ "accepted": "Zahtjev prihvaćen",
+ "acceptedDescription": "Prihvaćeno na {{amount}} koristeći {{source}}.",
+ "denied": "Zahtjev odbijen",
+ "deniedDescription": "Zahtjev je odbijen nakon što je razriješeno stanje novca.",
+ "paymentCaptured": "Uplata snimljena",
+ "paymentFailed": "Plaćanje nije uspjelo",
+ "paymentReturned": "Uplata vraćena",
+ "paymentDescription": "{{amount}} · {{method}}",
+ "resolutionDescription": "{{amount}} riješeno",
+ "resolutions": {
+ "refund": "Evidentiran povrat kartične uplate",
+ "external_return": "Zabilježen vanjski povrat",
+ "retained": "Novac zadržan"
+ },
+ "message": "{{kind}} poruka",
+ "messageDescription": "Status isporuke: {{status}}",
+ "actor": "Izvršio/la: {{actor}}",
+ "loadError": "Povijest revizije nije moguće učitati.",
+ "empty": "Nema zabilježenih revizijskih događaja.",
+ "loadMore": "Učitaj još",
+ "loadingMore": "Učitavanje dodatnih događaja…",
+ "loadMoreError": "Dodatne revizijske događaje nije moguće učitati.",
+ "events": {
+ "request_pending": "Zahtjev poslan",
+ "request_accepted": "Zahtjev prihvaćen",
+ "request_denied": "Zahtjev odbijen",
+ "request_updated": "Zahtjev ažuriran",
+ "installment_created": "Obrok izrađen",
+ "installment_updated": "Obrok ažuriran",
+ "installment_deleted": "Obrok izbrisan",
+ "allocation_recorded": "Plaćanje dodijeljeno",
+ "allocation_removed": "Dodjela plaćanja uklonjena",
+ "payment_pending": "Plaćanje na čekanju",
+ "payment_captured": "Plaćanje naplaćeno",
+ "payment_failed": "Plaćanje nije uspjelo",
+ "payment_recorded": "Plaćanje evidentirano",
+ "payment_updated": "Plaćanje ažurirano",
+ "resolution_refund": "Povrat novca evidentiran",
+ "resolution_external_return": "Vanjski povrat evidentiran",
+ "resolution_retained": "Novac zadržan",
+ "resolution_recorded": "Rješenje plaćanja evidentirano",
+ "email_pending": "E-pošta stavljena u red",
+ "email_processing": "Pokušana dostava e-pošte",
+ "email_sent": "E-pošta dostavljena",
+ "email_failed": "Dostava e-pošte nije uspjela",
+ "email_queued": "E-pošta stavljena u red",
+ "email_updated": "Dostava e-pošte ažurirana",
+ "stay_amended": "Prihvaćeni boravak izmijenjen"
+ }
+ }
}
}
diff --git a/apps/dashboard/src/locales/it.json b/apps/dashboard/src/locales/it.json
index a84e78b0..0eb6216b 100644
--- a/apps/dashboard/src/locales/it.json
+++ b/apps/dashboard/src/locales/it.json
@@ -144,7 +144,102 @@
"settingsSaved": "Impostazioni del motore di prenotazione salvate",
"settingsSaveFailed": "Salvataggio delle impostazioni non riuscito"
},
- "type": "Tipo"
+ "type": "Tipo",
+ "requestSettings": {
+ "autoConfirmDescription": "La conferma automatica si applica solo alle prenotazioni immediate; le richieste richiedono sempre una decisione del personale.",
+ "bookingMode": "Modalità di prenotazione",
+ "backgroundLoadError": "Non è stato possibile verificare le impostazioni più recenti. Le impostazioni caricate restano disponibili.",
+ "cardCollection": "Raccolta della carta",
+ "cardDescriptions": {
+ "disabled": "Gli ospiti inviano la richiesta senza aggiungere una carta.",
+ "optional": "Gli ospiti scelgono se salvare una carta per i pagamenti avviati dal personale.",
+ "required": "Gli ospiti devono salvare una carta prima dell’invio. Non viene effettuato alcun addebito automatico."
+ },
+ "cardPolicies": {
+ "disabled": "Disabilitata",
+ "optional": "Facoltativa",
+ "required": "Obbligatoria"
+ },
+ "conflictDescription": "Queste impostazioni sono cambiate da quando hai aperto la pagina. La bozza è ancora disponibile. Ricarica le impostazioni più recenti prima di continuare.",
+ "conflictTitle": "Conflitto nelle impostazioni",
+ "description": "Scegli se gli ospiti prenotano subito o inviano una richiesta da esaminare.",
+ "engineToggle": "Attiva il motore di prenotazione diretta",
+ "invalidQuestions": "Correggi il modulo ospite prima di salvare queste impostazioni.",
+ "loadError": "Impossibile caricare le impostazioni del motore di prenotazione.",
+ "loading": "Caricamento delle impostazioni del motore di prenotazione",
+ "modeDescriptions": {
+ "instant": "Un pagamento riuscito segue il flusso di prenotazione immediata esistente.",
+ "request": "L’invio crea una richiesta in attesa senza confermare una prenotazione."
+ },
+ "modes": {
+ "instant": "Prenotazione immediata",
+ "request": "Richiesta di prenotazione"
+ },
+ "requiredCardWarning": "Aggiungi una chiave pubblicabile Stripe prima di rendere obbligatoria la carta.",
+ "unsupportedCardWarning": "Il provider di pagamento configurato non supporta le carte salvate. Disattiva la raccolta delle carte o scegli Stripe.",
+ "reloadLatest": "Ricarica impostazioni recenti",
+ "reset": "Ripristina modifiche",
+ "retry": "Riprova",
+ "save": "Salva modifiche",
+ "saveError": "Impossibile salvare le impostazioni del motore di prenotazione.",
+ "saving": "Salvataggio delle modifiche",
+ "stripeKey": "Chiave pubblicabile Stripe",
+ "stripeKeyDescription": "Usa la chiave pubblicabile di questa struttura. Le chiavi segrete Stripe restano sul server.",
+ "stripeKeyPlaceholder": "pk_live_…",
+ "title": "Flusso delle richieste di prenotazione",
+ "unsaved": "Modifiche non salvate",
+ "unsupportedPublishBlocked": "Usa una versione più recente del pannello prima di pubblicare modifiche a questo modulo ospiti."
+ },
+ "questions": {
+ "active": "Attiva",
+ "activeQuestion": "Domanda attiva",
+ "add": "Aggiungi domanda",
+ "addOption": "Aggiungi opzione",
+ "addTitle": "Aggiungi una domanda",
+ "cancel": "Annulla",
+ "cancelEditor": "Chiudi l’editor della domanda",
+ "count": "Domande: {{count}} / {{max}}",
+ "description": "Crea il modulo ordinato che gli ospiti compilano prima di inviare una richiesta di prenotazione.",
+ "disable": "Disattiva {{label}}",
+ "duplicateIds": "Gli identificativi delle domande devono essere univoci per modificare il modulo.",
+ "edit": "Modifica {{label}}",
+ "editTitle": "Modifica domanda",
+ "emptyDescription": "Aggiungi solo le informazioni necessarie al team per valutare una richiesta.",
+ "emptyTitle": "Nessuna domanda nel modulo",
+ "enable": "Attiva {{label}}",
+ "idError": "Impossibile creare un identificativo univoco. Riprova.",
+ "inactive": "Inattiva",
+ "label": "Testo della domanda",
+ "labelRequired": "Inserisci il testo della domanda.",
+ "moveDown": "Sposta {{label}} in basso",
+ "moveOptionDown": "Sposta l’opzione {{number}} in basso",
+ "moveOptionUp": "Sposta l’opzione {{number}} in alto",
+ "moveUp": "Sposta {{label}} in alto",
+ "optionBlank": "Le opzioni non possono essere vuote.",
+ "optionDuplicate": "Le opzioni devono essere univoche.",
+ "optionLabel": "Opzione {{number}}",
+ "optionRequired": "Aggiungi almeno un’opzione.",
+ "optional": "Facoltativa",
+ "options": "Opzioni di risposta",
+ "remove": "Rimuovi {{label}}",
+ "removeOption": "Rimuovi l’opzione {{number}}",
+ "required": "Obbligatoria",
+ "requiredQuestion": "Domanda obbligatoria",
+ "save": "Salva domanda",
+ "title": "Schema del modulo ospite",
+ "type": "Tipo di domanda",
+ "unsupportedActiveDescription": "Questo pannello non può modificare o ripubblicare in sicurezza il modulo ospiti finché la domanda resta attiva.",
+ "unsupportedActiveTitle": "È attiva una domanda non supportata",
+ "unsupportedType": "Domanda non supportata",
+ "types": {
+ "short_text": "Testo breve",
+ "long_text": "Testo lungo",
+ "single_select": "Selezione singola",
+ "multi_select": "Selezione multipla",
+ "yes_no": "Sì / no",
+ "date": "Data"
+ }
+ }
},
"cashier": {
"amount": "Importo",
@@ -1122,6 +1217,7 @@
"dashboard": "Cruscotto",
"foliosBilling": "Folio e fatturazione",
"frontDesk": "Reception",
+ "bookingRequests": "Richieste di prenotazione",
"groups": "Gruppi",
"guests": "Ospiti",
"houseAccounts": "Conti interni",
@@ -1771,5 +1867,364 @@
"Bad Request": "Richiesta non valida",
"Resource not found": "Risorsa non trovata",
"Action not permitted": "Azione non consentita"
+ },
+ "bookingRequests": {
+ "access": {
+ "title": "Accesso limitato",
+ "description": "È necessaria l'autorizzazione per leggere le prenotazioni prima di poter esaminare le richieste di prenotazione."
+ },
+ "property": {
+ "title": "Scegli una proprietà",
+ "description": "Le richieste di prenotazione vengono esaminate una struttura alla volta, così i dati degli ospiti, dei pagamenti e delle decisioni restano protetti."
+ },
+ "notFound": {
+ "title": "Richiesta non trovata",
+ "description": "Questa richiesta non esiste nella struttura selezionata o non è più disponibile."
+ },
+ "common": {
+ "cancel": "Annulla",
+ "loading": "Caricamento richiesta di prenotazione…",
+ "status": "Stato",
+ "yes": "Sì",
+ "no": "No",
+ "notProvided": "Non fornito",
+ "retry": "Riprova"
+ },
+ "actions": {
+ "accept": "Accetta la richiesta",
+ "deny": "Rifiuta la richiesta",
+ "charge": "Addebita la carta",
+ "record": "Registra pagamento"
+ },
+ "statuses": {
+ "pending": "In sospeso",
+ "accepted": "Accettato",
+ "denied": "Negato"
+ },
+ "tabs": {
+ "label": "Area di lavoro della richiesta",
+ "overview": "Panoramica",
+ "payments": "Pagamenti e piano",
+ "messages": "Messaggi",
+ "audit": "Controllo"
+ },
+ "queue": {
+ "title": "Richieste di prenotazione",
+ "description": "Esamina le richieste degli ospiti, i dettagli del soggiorno e lo stato dei pagamenti prima di eseguire un'azione esplicita.",
+ "filters": "Filtri per la richiesta di prenotazione",
+ "guest": "Ospite",
+ "card": "Carta",
+ "stay": "Rimani",
+ "amount": "Importo richiesto",
+ "allStatuses": "Tutti gli stati",
+ "anyCard": "Qualsiasi stato della carta",
+ "cardSaved": "Carta salvata",
+ "noCard": "Nessuna carta",
+ "arrivalFrom": "Arrivo da",
+ "arrivalTo": "Arrivo a",
+ "sort": "Ordina per",
+ "sortOptions": {
+ "newest": "Prima il più recente",
+ "arrival": "Data di arrivo",
+ "guest": "Nome dell'ospite",
+ "amountDesc": "Importo richiesto più alto"
+ },
+ "clear": "Chiaro",
+ "loading": "Caricamento richieste di prenotazione...",
+ "loadError": "Impossibile caricare le richieste di prenotazione. Riprova.",
+ "empty": "Nessuna richiesta di prenotazione corrisponde a questi filtri.",
+ "total_one": "{{count}} richiesta",
+ "total_other": "{{count}} richieste",
+ "total": "{{count}} richieste",
+ "previous": "Precedente",
+ "next": "Avanti"
+ },
+ "detail": {
+ "back": "Tutte le richieste di prenotazione",
+ "reference": "{{reference}} · Inserito {{date}}",
+ "decisionActions": "Decisione",
+ "moneyActions": "Operazioni finanziarie",
+ "decisionComplete": "Decisione completata",
+ "independence": "Le decisioni di prenotazione e le operazioni finanziarie sono indipendenti. L'incasso di un pagamento non accetta mai una richiesta e l'accettazione non avvia mai un pagamento."
+ },
+ "amounts": {
+ "quoted": "Preventivo",
+ "submitted": "Inviato",
+ "current": "Attuale",
+ "accepted": "Accettato",
+ "difference": "Differenza",
+ "captured": "Incassato",
+ "returned": "Restituito",
+ "retained": "Trattenuto"
+ },
+ "overview": {
+ "stay": "Richiesta di soggiorno",
+ "dates": "Date",
+ "occupancy": "Occupazione",
+ "occupancyValue": "{{adults}} adulti · {{children}} bambini",
+ "roomType": "Tipo di camera",
+ "ratePlan": "Piano tariffario",
+ "guest": "Ospite e contatto",
+ "email": "E-mail",
+ "phone": "Telefono",
+ "specialRequests": "Richieste speciali",
+ "application": "Richiesta",
+ "noQuestions": "Non sono state fornite altre risposte con la richiesta.",
+ "priceComparison": "Confronto dei prezzi",
+ "card": "Scheda in archivio",
+ "cardGeneric": "carta",
+ "noCard": "Nessuna carta salvata",
+ "cardSafety": "Vengono visualizzati solo il circuito della carta e le ultime quattro cifre. Il personale deve avviare esplicitamente ogni addebito.",
+ "decision": "Registro delle decisioni",
+ "priceSource": "Fonte del prezzo"
+ },
+ "priceSources": {
+ "submitted": "Preventivo inviato",
+ "current": "Preventivo attuale",
+ "custom": "Totale personalizzato"
+ },
+ "accept": {
+ "title": "Accetta la richiesta di prenotazione",
+ "priceChoice": "Prezzo accettato",
+ "submitted": "Preventivo inviato",
+ "submittedDescription": "Utilizza l'importo che l'ospite ha visto quando è stata inviata la richiesta.",
+ "current": "Preventivo attuale",
+ "currentDescription": "Ricontrolla la disponibilità e calcola il preventivo attuale autorevole in fase di accettazione.",
+ "custom": "Totale personalizzato",
+ "customDescription": "Imposta un totale esplicito e registra il motivo della differenza.",
+ "recheckedOnAccept": "Ricontrollato all'accettazione",
+ "enterAmount": "Inserisci un importo",
+ "customTotal": "Totale personalizzato",
+ "customReason": "Motivo del totale personalizzato",
+ "independence": "Accettando si crea la prenotazione. Non addebita alcun importo sulla carta salvata né dipende dallo stato del pagamento.",
+ "accepting": "Accettazione...",
+ "error": "Non è stato possibile accettare la richiesta. Controlla la disponibilità attuale e riprova."
+ },
+ "modifyStay": {
+ "action": "Modifica soggiorno", "title": "Modifica soggiorno accettato", "activeStay": "Soggiorno attivo", "proposedStay": "Soggiorno proposto", "originalRequest": "Richiesta originale",
+ "arrivalDate": "Data di arrivo", "departureDate": "Data di partenza", "invalidDates": "La partenza deve essere successiva all'arrivo ed entrambe le date devono essere valide.",
+ "checking": "Verifica dell'intero soggiorno e del preventivo attuale…", "previewError": "Al momento non è possibile calcolare questo soggiorno. Controlla la disponibilità e riprova.",
+ "priceChoice": "Base tariffaria per il soggiorno modificato", "prior": "Base accettata precedente", "priorDescription": "Le date sovrapposte mantengono le righe accettate; le notti aggiunte usano la tariffa accettata di confine più vicina.",
+ "current": "Preventivo attuale", "currentDescription": "Usa le tariffe autorevoli disponibili ora per l'intero soggiorno proposto.", "custom": "Totale personalizzato", "customDescription": "Imposta un totale operativo positivo e registra il motivo.",
+ "awaitingQuote": "Preventivo in attesa", "enterAmount": "Inserisci un importo", "customTotal": "Totale personalizzato", "customReason": "Motivo del totale personalizzato",
+ "apply": "Applica modifica soggiorno", "applying": "Aggiornamento…", "commitError": "Non è stato possibile modificare il soggiorno. Controlla disponibilità e preventivo aggiornati."
+ },
+ "deny": {
+ "title": "Rifiuta la richiesta di prenotazione",
+ "reason": "Motivo di rifiuto",
+ "unresolved": "{{amount}} rimane irrisolto",
+ "unresolvedDirection": "Rimborsare un pagamento con carta salvato, registrare un reso esterno o trattenere l'importo con un motivo prima di rifiutarlo.",
+ "resolveFirst": "Risolvi prima i pagamenti",
+ "confirm": "Conferma il rifiuto",
+ "denying": "Rifiuto in corso…",
+ "error": "Non è stato possibile rifiutare la richiesta. Controlla lo stato dei pagamenti e riprova.",
+ "moneyLoading": "Verifica dello stato dei pagamenti prima del rifiuto…",
+ "moneyLoadError": "Non è stato possibile verificare lo stato dei pagamenti. Il rifiuto resta bloccato.",
+ "retryMoney": "Verifica di nuovo i pagamenti"
+ },
+ "validation": {
+ "positiveAmount": "Inserisci un importo maggiore di zero.",
+ "required": "Inserisci un importo.",
+ "format": "Inserisci un importo decimale valido.",
+ "positive": "Inserisci un importo maggiore di zero.",
+ "precision": "Usa solo i decimali supportati da questa valuta.",
+ "unsupportedCurrency": "I decimali di questa valuta non sono supportati dal registro.",
+ "maximum": "Inserisci un importo entro il massimo consentito."
+ },
+ "payments": {
+ "requestSummary": "Riepilogo pagamenti della richiesta",
+ "independence": "Le decisioni relative alla prenotazione non dipendono dallo stato del pagamento.",
+ "plan": "Piano di pagamento",
+ "noAutomatic": "Niente viene addebitato automaticamente. Le pietre miliari sono promemoria per le azioni avviate dallo staff.",
+ "noAutomaticTitle": "Niente viene addebitato automaticamente.",
+ "noAutomaticDescription": "Le pietre miliari sono promemoria per le azioni avviate dallo staff.",
+ "addInstallment": "Aggiungi rata",
+ "editInstallment": "Modifica rata",
+ "installmentLabel": "Etichetta di rata",
+ "amountType": "Tipo di importo",
+ "fixedAmount": "Importo fisso",
+ "percentage": "Percentuale",
+ "milestone": "Traguardo dovuto",
+ "dueDate": "Data di scadenza",
+ "saveInstallment": "Salva rata",
+ "installmentError": "Impossibile salvare la rata.",
+ "noInstallments": "Nessuna rata del piano di pagamento ancora.",
+ "allocated": "{{allocated}} di {{total}} assegnato",
+ "editLabel": "Modifica {{label}}",
+ "deleteLabel": "Elimina {{label}}",
+ "removeRemainingAmount": "Rimuovi l'importo residuo — {{amount}} rimarrà pagato",
+ "allocateLabel": "Assegna il pagamento a {{label}}",
+ "allocateTo": "Assegna il pagamento a {{label}}",
+ "movement": "Pagamento incassato",
+ "allocate": "Assegnare",
+ "allocationError": "Non è stato possibile assegnare il pagamento.",
+ "availableForAllocation": "{{available}} disponibile · {{allocated}} assegnato · {{method}}",
+ "allocationAvailability": "{{available}} disponibile · {{allocated}} assegnato",
+ "moveUp": "Sposta {{label}} in alto",
+ "moveDown": "Sposta {{label}} in basso",
+ "reorderError": "Non è stato possibile salvare l’ordine delle rate. Riprova.",
+ "movements": "Movimenti di pagamento",
+ "noMovements": "Nessun movimento di pagamento.",
+ "savedCardProvenance": "Carta salvata · {{brand}} •••• {{lastFour}}",
+ "externalProvenance": "Esterno · {{method}} · {{reference}}",
+ "loadError": "Impossibile caricare il piano di pagamento e la cronologia dei movimenti.",
+ "folioSummary": "Riepilogo del conto operativo",
+ "acceptedDeal": "Totale accettato",
+ "activeStayTotal": "Totale soggiorno attivo",
+ "folioCharges": "Addebiti del conto",
+ "folioPayments": "Pagamenti del conto",
+ "balanceDue": "Saldo dovuto",
+ "folioError": "Impossibile caricare il riepilogo del conto collegato."
+ },
+ "installmentStatuses": {
+ "unpaid": "Non pagato",
+ "partial": "Parziale",
+ "paid": "Pagato"
+ },
+ "milestones": {
+ "manual": "Follow-up manuale",
+ "arrival": "Scadenza all'arrivo",
+ "checkout": "Dovuto alla cassa",
+ "date": "Data specifica",
+ "dateValue": "Scadenza {{date}}"
+ },
+ "methods": {
+ "credit_card": "carta di credito",
+ "debit_card": "carta di debito",
+ "cash": "contanti",
+ "bank_transfer": "bonifico bancario",
+ "pix": "PIX",
+ "other": "altro"
+ },
+ "paymentStatuses": {
+ "pending": "In sospeso",
+ "authorized": "Autorizzato",
+ "captured": "Incassato",
+ "settled": "Contabilizzato",
+ "refunded": "Rimborsato",
+ "partially_refunded": "Parzialmente rimborsato",
+ "failed": "Fallito",
+ "voided": "Annullato"
+ },
+ "paymentActions": {
+ "amount": "Importo",
+ "method": "Metodo di pagamento",
+ "processedAt": "Elaborato a",
+ "provider": "Fornitore (facoltativo)",
+ "reference": "Riferimento",
+ "notes": "Note (facoltativo)",
+ "saving": "Salvataggio…",
+ "error": "Impossibile completare l'operazione finanziaria. I dati inseriti restano disponibili.",
+ "paymentRequired": "Scegli prima un movimento di pagamento.",
+ "charge": {
+ "title": "Addebita la carta salvata",
+ "action": "Addebita la carta salvata",
+ "description": "Si tratta di un addebito esplicito avviato dal personale. Non accoglie la richiesta."
+ },
+ "external": {
+ "title": "Registra il pagamento esterno",
+ "action": "Registra il pagamento esterno",
+ "description": "Registra un pagamento già incassato al di fuori del gateway delle carte salvate."
+ },
+ "refund": {
+ "title": "Rimborso del pagamento con carta salvata",
+ "action": "Rimborso",
+ "description": "Restituisci parte o tutto il pagamento del gateway."
+ },
+ "external_return": {
+ "title": "Registra il rimborso esterno",
+ "action": "Registra rimborso",
+ "description": "Registra la restituzione di un pagamento incassato esternamente."
+ },
+ "retain": {
+ "title": "Trattieni l'importo",
+ "action": "Trattieni l'importo",
+ "open": "Trattieni con motivazione",
+ "description": "Risolvi questo importo come trattenuto prima del rifiuto. Un motivo commerciale è obbligatorio.",
+ "reason": "Motivo della trattenuta"
+ }
+ },
+ "messages": {
+ "title": "Consegne di messaggi",
+ "description": "L'e-mail transazionale è una conseguenza delle azioni del personale. Una mancata consegna non annulla mai una decisione o un'operazione finanziaria.",
+ "loading": "Caricamento cronologia messaggi…",
+ "loadError": "Impossibile caricare la cronologia dei messaggi.",
+ "empty": "Nessun messaggio transazionale è stato ancora registrato.",
+ "retry": "Riprovare la consegna",
+ "retryError": "Non è stato possibile ritentare la consegna fallita.",
+ "attempts_one": "{{count}} tentativo",
+ "attempts_other": "{{count}} tentativi",
+ "attempts": "{{count}} tentativi",
+ "actorNote": "Un nuovo tentativo manuale viene registrato con l'identità dello staff."
+ },
+ "messageKinds": {
+ "receipt": "Richiedi ricevuta",
+ "accepted": "Accettazione",
+ "denied": "Rifiuto",
+ "payment": "Pagamento",
+ "refund": "Rimborso",
+ "failure": "Mancato pagamento"
+ },
+ "messageStatuses": {
+ "pending": "In sospeso",
+ "processing": "Elaborazione",
+ "sent": "Inviato",
+ "failed": "Fallito"
+ },
+ "audit": {
+ "title": "Cronologia aziendale",
+ "description": "Una visione operativa sicura derivata dai record di richiesta, movimento, risoluzione e consegna a disposizione del personale.",
+ "submitted": "Richiesta inviata",
+ "submittedDescription": "La domanda ospite e l'istantanea del preventivo inviata sono state registrate.",
+ "accepted": "Richiesta accettata",
+ "acceptedDescription": "Accettato al {{amount}} utilizzando il {{source}}.",
+ "denied": "Richiesta respinta",
+ "deniedDescription": "La richiesta è stata respinta dopo che il suo stato monetario è stato risolto.",
+ "paymentCaptured": "Pagamento incassato",
+ "paymentFailed": "Pagamento non riuscito",
+ "paymentReturned": "Pagamento restituito",
+ "paymentDescription": "{{amount}} · {{method}}",
+ "resolutionDescription": "{{amount}} risolto",
+ "resolutions": {
+ "refund": "Rimborso gateway registrato",
+ "external_return": "Rimborso esterno registrato",
+ "retained": "Importo trattenuto"
+ },
+ "message": "Messaggio {{kind}}",
+ "messageDescription": "Stato di consegna: {{status}}",
+ "actor": "Eseguito da: {{actor}}",
+ "loadError": "Non è stato possibile caricare la cronologia di audit.",
+ "empty": "Non è stato registrato alcun evento di audit.",
+ "loadMore": "Carica altri eventi",
+ "loadingMore": "Caricamento di altri eventi…",
+ "loadMoreError": "Non è stato possibile caricare altri eventi di audit.",
+ "events": {
+ "request_pending": "Richiesta inviata",
+ "request_accepted": "Richiesta accettata",
+ "request_denied": "Richiesta rifiutata",
+ "request_updated": "Richiesta aggiornata",
+ "installment_created": "Rata creata",
+ "installment_updated": "Rata aggiornata",
+ "installment_deleted": "Rata eliminata",
+ "allocation_recorded": "Pagamento assegnato",
+ "allocation_removed": "Assegnazione del pagamento rimossa",
+ "payment_pending": "Pagamento in sospeso",
+ "payment_captured": "Pagamento acquisito",
+ "payment_failed": "Pagamento non riuscito",
+ "payment_recorded": "Pagamento registrato",
+ "payment_updated": "Pagamento aggiornato",
+ "resolution_refund": "Rimborso registrato",
+ "resolution_external_return": "Restituzione esterna registrata",
+ "resolution_retained": "Importo trattenuto",
+ "resolution_recorded": "Risoluzione del pagamento registrata",
+ "email_pending": "E-mail in coda",
+ "email_processing": "Tentativo di consegna e-mail",
+ "email_sent": "E-mail consegnata",
+ "email_failed": "Consegna e-mail non riuscita",
+ "email_queued": "E-mail in coda",
+ "email_updated": "Consegna e-mail aggiornata",
+ "stay_amended": "Soggiorno accettato modificato"
+ }
+ }
}
}
diff --git a/apps/dashboard/src/locales/pt-BR.json b/apps/dashboard/src/locales/pt-BR.json
index cd150847..b4cca240 100644
--- a/apps/dashboard/src/locales/pt-BR.json
+++ b/apps/dashboard/src/locales/pt-BR.json
@@ -133,7 +133,102 @@
"settingsSaved": "Configurações do sistema de reservas salvas",
"settingsSaveFailed": "Falha ao salvar configurações"
},
- "type": "Tipo"
+ "type": "Tipo",
+ "requestSettings": {
+ "autoConfirmDescription": "A confirmação automática vale somente para reservas instantâneas; solicitações sempre exigem uma decisão da equipe.",
+ "bookingMode": "Modo de reserva",
+ "backgroundLoadError": "Não foi possível verificar as configurações mais recentes. As configurações carregadas continuam disponíveis.",
+ "cardCollection": "Coleta de cartão",
+ "cardDescriptions": {
+ "disabled": "Os hóspedes enviam a solicitação sem adicionar um cartão.",
+ "optional": "Os hóspedes escolhem se desejam salvar um cartão para pagamentos iniciados pela equipe.",
+ "required": "Os hóspedes devem salvar um cartão antes do envio. Nenhuma cobrança é feita automaticamente."
+ },
+ "cardPolicies": {
+ "disabled": "Desativada",
+ "optional": "Opcional",
+ "required": "Obrigatória"
+ },
+ "conflictDescription": "Estas configurações mudaram desde que você abriu a página. Seu rascunho continua aqui. Recarregue as configurações mais recentes antes de editar novamente.",
+ "conflictTitle": "Conflito de configurações",
+ "description": "Escolha se os hóspedes reservam na hora ou enviam uma solicitação para análise da equipe.",
+ "engineToggle": "Ativar o sistema de reservas diretas",
+ "invalidQuestions": "Corrija o formulário do hóspede antes de salvar estas configurações.",
+ "loadError": "Não foi possível carregar as configurações do sistema de reservas.",
+ "loading": "Carregando as configurações do sistema de reservas",
+ "modeDescriptions": {
+ "instant": "Um checkout concluído segue o fluxo atual de reserva instantânea.",
+ "request": "O envio cria uma solicitação pendente sem confirmar uma reserva."
+ },
+ "modes": {
+ "instant": "Reserva instantânea",
+ "request": "Solicitação de reserva"
+ },
+ "requiredCardWarning": "Adicione uma chave publicável da Stripe antes de exigir o cartão.",
+ "unsupportedCardWarning": "O provedor de pagamento configurado não aceita cartões salvos. Desative a coleta de cartões ou escolha a Stripe.",
+ "reloadLatest": "Recarregar configurações mais recentes",
+ "reset": "Redefinir alterações",
+ "retry": "Tentar novamente",
+ "save": "Salvar alterações",
+ "saveError": "Não foi possível salvar as configurações do sistema de reservas.",
+ "saving": "Salvando alterações",
+ "stripeKey": "Chave publicável da Stripe",
+ "stripeKeyDescription": "Use a chave publicável desta propriedade. As chaves secretas da Stripe permanecem no servidor.",
+ "stripeKeyPlaceholder": "pk_live_…",
+ "title": "Fluxo de solicitação de reserva",
+ "unsaved": "Alterações não salvas",
+ "unsupportedPublishBlocked": "Use uma versão mais recente do painel antes de publicar alterações neste formulário de hóspedes."
+ },
+ "questions": {
+ "active": "Ativa",
+ "activeQuestion": "Pergunta ativa",
+ "add": "Adicionar pergunta",
+ "addOption": "Adicionar opção",
+ "addTitle": "Adicionar uma pergunta",
+ "cancel": "Cancelar",
+ "cancelEditor": "Fechar o editor de perguntas",
+ "count": "Perguntas: {{count}} / {{max}}",
+ "description": "Monte o formulário ordenado que os hóspedes preenchem antes de enviar uma solicitação de reserva.",
+ "disable": "Desativar {{label}}",
+ "duplicateIds": "Os identificadores das perguntas devem ser únicos para editar este formulário.",
+ "edit": "Editar {{label}}",
+ "editTitle": "Editar pergunta",
+ "emptyDescription": "Adicione apenas as informações necessárias para a equipe analisar uma solicitação.",
+ "emptyTitle": "Nenhuma pergunta no formulário",
+ "enable": "Ativar {{label}}",
+ "idError": "Não foi possível criar um identificador único. Tente novamente.",
+ "inactive": "Inativa",
+ "label": "Texto da pergunta",
+ "labelRequired": "Digite o texto da pergunta.",
+ "moveDown": "Mover {{label}} para baixo",
+ "moveOptionDown": "Mover a opção {{number}} para baixo",
+ "moveOptionUp": "Mover a opção {{number}} para cima",
+ "moveUp": "Mover {{label}} para cima",
+ "optionBlank": "As opções não podem ficar em branco.",
+ "optionDuplicate": "As opções devem ser únicas.",
+ "optionLabel": "Opção {{number}}",
+ "optionRequired": "Adicione pelo menos uma opção.",
+ "optional": "Opcional",
+ "options": "Opções de resposta",
+ "remove": "Remover {{label}}",
+ "removeOption": "Remover a opção {{number}}",
+ "required": "Obrigatória",
+ "requiredQuestion": "Pergunta obrigatória",
+ "save": "Salvar pergunta",
+ "title": "Estrutura do formulário do hóspede",
+ "type": "Tipo de pergunta",
+ "unsupportedActiveDescription": "Este painel não pode alterar nem republicar o formulário de hóspedes com segurança enquanto essa pergunta estiver ativa.",
+ "unsupportedActiveTitle": "Há uma pergunta não compatível ativa",
+ "unsupportedType": "Pergunta não compatível",
+ "types": {
+ "short_text": "Texto curto",
+ "long_text": "Texto longo",
+ "single_select": "Seleção única",
+ "multi_select": "Seleção múltipla",
+ "yes_no": "Sim / não",
+ "date": "Data"
+ }
+ }
},
"cashier": {
"amount": "Valor",
@@ -1045,6 +1140,7 @@
"dashboard": "Painel",
"foliosBilling": "Contas e Faturamento",
"frontDesk": "Recepção",
+ "bookingRequests": "Solicitações de reserva",
"groups": "Grupos",
"guests": "Hóspedes",
"houseAccounts": "Contas Internas",
@@ -1656,5 +1752,364 @@
"Bad Request": "Requisição inválida",
"Resource not found": "Recurso não encontrado",
"Action not permitted": "Ação não permitida"
+ },
+ "bookingRequests": {
+ "access": {
+ "title": "Acesso restrito",
+ "description": "Você precisa de permissão para ler as reservas antes de poder analisar as solicitações de reserva."
+ },
+ "property": {
+ "title": "Escolha um imóvel",
+ "description": "As solicitações de reserva são analisadas em uma propriedade por vez, para que os registros de hóspedes, dinheiro e decisões permaneçam no escopo com segurança."
+ },
+ "notFound": {
+ "title": "Solicitação não encontrada",
+ "description": "Esta solicitação não existe na propriedade selecionada ou não está mais disponível."
+ },
+ "common": {
+ "cancel": "Cancelar",
+ "loading": "Carregando solicitação de reserva…",
+ "status": "Estado",
+ "yes": "Sim",
+ "no": "Não",
+ "notProvided": "Não fornecido",
+ "retry": "Tentar novamente"
+ },
+ "actions": {
+ "accept": "Aceitar solicitação",
+ "deny": "Negar solicitação",
+ "charge": "Cobrar no cartão",
+ "record": "Registrar pagamento"
+ },
+ "statuses": {
+ "pending": "Pendente",
+ "accepted": "Aceito",
+ "denied": "Negado"
+ },
+ "tabs": {
+ "label": "Solicitar espaço de trabalho",
+ "overview": "Visão geral",
+ "payments": "Pagamentos e plano",
+ "messages": "Mensagens",
+ "audit": "Auditoria"
+ },
+ "queue": {
+ "title": "Pedidos de reserva",
+ "description": "Revise as solicitações dos hóspedes, os detalhes da estadia e o estado financeiro antes de tomar uma ação explícita.",
+ "filters": "Filtros de solicitação de reserva",
+ "guest": "Convidado",
+ "card": "Cartão",
+ "stay": "Fique",
+ "amount": "Valor solicitado",
+ "allStatuses": "Todos os status",
+ "anyCard": "Qualquer estado do cartão",
+ "cardSaved": "Cartão salvo",
+ "noCard": "Sem cartão",
+ "arrivalFrom": "Chegada de",
+ "arrivalTo": "Chegada a",
+ "sort": "Classificar por",
+ "sortOptions": {
+ "newest": "O mais novo primeiro",
+ "arrival": "Data de chegada",
+ "guest": "Nome do convidado",
+ "amountDesc": "Maior valor solicitado"
+ },
+ "clear": "Limpar",
+ "loading": "Carregando solicitações de reserva…",
+ "loadError": "Não foi possível carregar os pedidos de reserva. Tente novamente.",
+ "empty": "Nenhum pedido de reserva corresponde a estes filtros.",
+ "total_one": "{{count}} solicitação",
+ "total_other": "{{count}} solicitações",
+ "total": "{{count}} solicitações",
+ "previous": "Anterior",
+ "next": "Próximo"
+ },
+ "detail": {
+ "back": "Todos os pedidos de reserva",
+ "reference": "{{reference}} · Enviado {{date}}",
+ "decisionActions": "Decisão",
+ "moneyActions": "Dinheiro",
+ "decisionComplete": "Decisão concluída",
+ "independence": "As decisões de reserva e as ações monetárias são independentes. Receber o pagamento nunca aceita uma solicitação e a aceitação nunca aciona o pagamento."
+ },
+ "amounts": {
+ "quoted": "Cotação",
+ "submitted": "Enviado",
+ "current": "Atual",
+ "accepted": "Aceito",
+ "difference": "Diferença",
+ "captured": "Cobrado",
+ "returned": "Devolvido",
+ "retained": "Retido"
+ },
+ "overview": {
+ "stay": "Solicitação de estadia",
+ "dates": "Datas",
+ "occupancy": "Ocupação",
+ "occupancyValue": "{{adults}} adultos · {{children}} crianças",
+ "roomType": "Tipo de quarto",
+ "ratePlan": "Plano de tarifas",
+ "guest": "Convidado e contato",
+ "email": "E-mail",
+ "phone": "Telefone",
+ "specialRequests": "Pedidos especiais",
+ "application": "Solicitação de reserva",
+ "noQuestions": "Nenhuma pergunta adicional da solicitação foi enviada.",
+ "priceComparison": "Comparação de preços",
+ "card": "Cartão em arquivo",
+ "cardGeneric": "cartão",
+ "noCard": "Nenhum cartão salvo",
+ "cardSafety": "Apenas a marca do cartão seguro e os últimos quatro dígitos são mostrados. A equipe deve iniciar todas as cobranças.",
+ "decision": "Registro de decisão",
+ "priceSource": "Fonte de preço"
+ },
+ "priceSources": {
+ "submitted": "Cotação enviada",
+ "current": "Cotação atual",
+ "custom": "Total personalizado"
+ },
+ "accept": {
+ "title": "Aceitar pedido de reserva",
+ "priceChoice": "Preço aceito",
+ "submitted": "Cotação enviada",
+ "submittedDescription": "Use o valor que o hóspede viu quando a solicitação foi enviada.",
+ "current": "Cotação atual",
+ "currentDescription": "Verifique novamente a disponibilidade e calcule a cotação atual oficial durante a aceitação.",
+ "custom": "Total personalizado",
+ "customDescription": "Defina um total explícito e registre por que ele difere.",
+ "recheckedOnAccept": "Verificado novamente na aceitação",
+ "enterAmount": "Insira um valor",
+ "customTotal": "Total personalizado",
+ "customReason": "Motivo do total personalizado",
+ "independence": "Aceitar cria a reserva. Não cobra o cartão salvo nem depende do estado do pagamento.",
+ "accepting": "Aceitando…",
+ "error": "A solicitação não pôde ser aceita. Revise a disponibilidade atual e tente novamente."
+ },
+ "modifyStay": {
+ "action": "Alterar estadia", "title": "Alterar estadia aceita", "activeStay": "Estadia ativa", "proposedStay": "Estadia proposta", "originalRequest": "Pedido original",
+ "arrivalDate": "Data de chegada", "departureDate": "Data de saída", "invalidDates": "A saída deve ser posterior à chegada e ambas as datas devem ser válidas.",
+ "checking": "Verificando toda a estadia e a cotação atual…", "previewError": "Não é possível calcular esta estadia agora. Revise a disponibilidade e tente novamente.",
+ "priceChoice": "Base de tarifa para a estadia alterada", "prior": "Base aceita anterior", "priorDescription": "As datas sobrepostas mantêm as linhas aceitas; noites adicionadas usam a tarifa limite aceita mais próxima.",
+ "current": "Cotação atual", "currentDescription": "Use as tarifas oficiais disponíveis agora para toda a estadia proposta.", "custom": "Total personalizado", "customDescription": "Defina um total operacional positivo e registre o motivo.",
+ "awaitingQuote": "Aguardando cotação", "enterAmount": "Informe um valor", "customTotal": "Total personalizado", "customReason": "Motivo do total personalizado",
+ "apply": "Confirmar alteração da estadia", "applying": "Confirmando…", "commitError": "Não foi possível alterar a estadia. Revise a disponibilidade e a cotação mais recentes."
+ },
+ "deny": {
+ "title": "Negar solicitação de reserva",
+ "reason": "Motivo da negação",
+ "unresolved": "{{amount}} permanece sem solução",
+ "unresolvedDirection": "Reembolse um pagamento com cartão salvo, registre uma devolução externa ou retenha o valor com um motivo antes de negar.",
+ "resolveFirst": "Resolva o dinheiro primeiro",
+ "confirm": "Confirmar negação",
+ "denying": "Negando…",
+ "error": "A solicitação não pôde ser negada. Revise o estado dos pagamentos e tente novamente.",
+ "moneyLoading": "Verificando o estado dos pagamentos antes de negar…",
+ "moneyLoadError": "Não foi possível verificar o estado dos pagamentos. A negação continua bloqueada.",
+ "retryMoney": "Verificar os pagamentos novamente"
+ },
+ "validation": {
+ "positiveAmount": "Insira um valor maior que zero.",
+ "required": "Insira um valor.",
+ "format": "Insira um valor decimal válido.",
+ "positive": "Insira um valor maior que zero.",
+ "precision": "Use somente as casas decimais aceitas por esta moeda.",
+ "unsupportedCurrency": "As casas decimais desta moeda não são aceitas pelo livro-razão.",
+ "maximum": "Insira um valor dentro do máximo permitido."
+ },
+ "payments": {
+ "requestSummary": "Resumo de pagamentos da solicitação",
+ "independence": "As decisões de reserva não dependem do estado do pagamento.",
+ "plan": "Plano de pagamento",
+ "noAutomatic": "Nada é cobrado automaticamente. Marcos são lembretes para ações iniciadas pela equipe.",
+ "noAutomaticTitle": "Nada é cobrado automaticamente.",
+ "noAutomaticDescription": "Marcos são lembretes para ações iniciadas pela equipe.",
+ "addInstallment": "Adicionar parcela",
+ "editInstallment": "Editar parcela",
+ "installmentLabel": "Etiqueta de parcelamento",
+ "amountType": "Tipo de valor",
+ "fixedAmount": "Valor fixo",
+ "percentage": "Porcentagem",
+ "milestone": "Marco devido",
+ "dueDate": "Data de vencimento",
+ "saveInstallment": "Salvar parcela",
+ "installmentError": "A parcela não pôde ser salva.",
+ "noInstallments": "Ainda não há parcelamento do plano de pagamento.",
+ "allocated": "{{allocated}} de {{total}} alocados",
+ "editLabel": "Editar {{label}}",
+ "deleteLabel": "Excluir {{label}}",
+ "removeRemainingAmount": "Remover valor restante — {{amount}} permanecerá pago",
+ "allocateLabel": "Alocar pagamento para {{label}}",
+ "allocateTo": "Alocar pagamento para {{label}}",
+ "movement": "Pagamento cobrado",
+ "allocate": "Alocar",
+ "allocationError": "O pagamento não pôde ser alocado.",
+ "availableForAllocation": "{{available}} disponível · {{allocated}} alocado · {{method}}",
+ "allocationAvailability": "{{available}} disponível · {{allocated}} alocado",
+ "moveUp": "Mover {{label}} para cima",
+ "moveDown": "Mover {{label}} para baixo",
+ "reorderError": "Não foi possível salvar a ordem das parcelas. Tente novamente.",
+ "movements": "Movimentações de pagamento",
+ "noMovements": "Ainda não há movimentações de pagamento.",
+ "savedCardProvenance": "Cartão salvo · {{brand}} •••• {{lastFour}}",
+ "externalProvenance": "Externo · {{method}} · {{reference}}",
+ "loadError": "Não foi possível carregar o plano de pagamento e o histórico de movimentos.",
+ "folioSummary": "Resumo do fólio operacional",
+ "acceptedDeal": "Total aceito",
+ "activeStayTotal": "Total da estadia ativa",
+ "folioCharges": "Cobranças de fólio",
+ "folioPayments": "Pagamentos em fólio",
+ "balanceDue": "Saldo devido",
+ "folioError": "O resumo do fólio vinculado não pôde ser carregado."
+ },
+ "installmentStatuses": {
+ "unpaid": "Não pago",
+ "partial": "Parcial",
+ "paid": "Pago"
+ },
+ "milestones": {
+ "manual": "Acompanhamento manual",
+ "arrival": "Vencimento na chegada",
+ "checkout": "Vencimento na finalização da compra",
+ "date": "Data específica",
+ "dateValue": "Vencimento {{date}}"
+ },
+ "methods": {
+ "credit_card": "cartão de crédito",
+ "debit_card": "cartão de débito",
+ "cash": "dinheiro",
+ "bank_transfer": "transferência bancária",
+ "pix": "PIX",
+ "other": "outro"
+ },
+ "paymentStatuses": {
+ "pending": "Pendente",
+ "authorized": "Autorizado",
+ "captured": "Cobrado",
+ "settled": "Contabilizado",
+ "refunded": "Reembolsado",
+ "partially_refunded": "Parcialmente reembolsado",
+ "failed": "Falha",
+ "voided": "Anulado"
+ },
+ "paymentActions": {
+ "amount": "Quantidade",
+ "method": "Método de pagamento",
+ "processedAt": "Processado em",
+ "provider": "Provedor (opcional)",
+ "reference": "Referência",
+ "notes": "Notas (opcional)",
+ "saving": "Salvando…",
+ "error": "A ação monetária não pôde ser concluída. Os detalhes inseridos ainda estão aqui.",
+ "paymentRequired": "Escolha primeiro um movimento de pagamento.",
+ "charge": {
+ "title": "Cobrar no cartão salvo",
+ "action": "Cobrar no cartão salvo",
+ "description": "Esta é uma cobrança explícita iniciada pela equipe. Não aceita o pedido."
+ },
+ "external": {
+ "title": "Registrar pagamento externo",
+ "action": "Registrar pagamento externo",
+ "description": "Registre o dinheiro já coletado fora do gateway do cartão salvo."
+ },
+ "refund": {
+ "title": "Reembolsar pagamento com cartão salvo",
+ "action": "Reembolso",
+ "description": "Devolva parte ou todo esse pagamento de gateway."
+ },
+ "external_return": {
+ "title": "Registrar devolução externa",
+ "action": "Registrar devolução",
+ "description": "Registre que o dinheiro arrecadado externamente foi devolvido."
+ },
+ "retain": {
+ "title": "Reter dinheiro",
+ "action": "Reter dinheiro",
+ "open": "Reter com justificativa",
+ "description": "Resolva esse valor como retido antes da negação. Um motivo comercial é obrigatório.",
+ "reason": "Razão para reter dinheiro"
+ }
+ },
+ "messages": {
+ "title": "Entregas de mensagens",
+ "description": "O email transacional é uma consequência das ações da equipe. Uma falha na entrega nunca reverte uma decisão ou movimento de dinheiro.",
+ "loading": "Carregando histórico de mensagens…",
+ "loadError": "Não foi possível carregar o histórico de mensagens.",
+ "empty": "Nenhuma mensagem transacional foi registrada ainda.",
+ "retry": "Tentar entrega novamente",
+ "retryError": "A falha na entrega não pôde ser tentada novamente.",
+ "attempts_one": "{{count}} tentativa",
+ "attempts_other": "{{count}} tentativas",
+ "attempts": "{{count}} tentativas",
+ "actorNote": "Uma nova tentativa manual é registrada com a identidade de sua equipe."
+ },
+ "messageKinds": {
+ "receipt": "Solicitar recibo",
+ "accepted": "Aceitação",
+ "denied": "Recusa",
+ "payment": "Pagamento",
+ "refund": "Reembolso",
+ "failure": "Falha no pagamento"
+ },
+ "messageStatuses": {
+ "pending": "Pendente",
+ "processing": "Processamento",
+ "sent": "Enviado",
+ "failed": "Falha"
+ },
+ "audit": {
+ "title": "Cronograma de negócios",
+ "description": "Uma visão operacional segura derivada dos registros de solicitação, movimentação, resolução e entrega disponíveis para a equipe.",
+ "submitted": "Solicitação enviada",
+ "submittedDescription": "A solicitação do hóspede e o instantâneo da cotação enviada foram registrados.",
+ "accepted": "Solicitação aceita",
+ "acceptedDescription": "Aceito em {{amount}} usando o {{source}}.",
+ "denied": "Solicitação negada",
+ "deniedDescription": "O pedido foi negado depois que seu estado monetário foi resolvido.",
+ "paymentCaptured": "Pagamento cobrado",
+ "paymentFailed": "Falha no pagamento",
+ "paymentReturned": "Pagamento devolvido",
+ "paymentDescription": "{{amount}} · {{method}}",
+ "resolutionDescription": "{{amount}} resolvido",
+ "resolutions": {
+ "refund": "Reembolso de gateway registrado",
+ "external_return": "Devolução externa registrada",
+ "retained": "Dinheiro retido"
+ },
+ "message": "mensagem {{kind}}",
+ "messageDescription": "Status de entrega: {{status}}",
+ "actor": "Executado por: {{actor}}",
+ "loadError": "Não foi possível carregar o histórico de auditoria.",
+ "empty": "Nenhum evento de auditoria foi registrado.",
+ "loadMore": "Carregar mais",
+ "loadingMore": "Carregando mais eventos…",
+ "loadMoreError": "Não foi possível carregar mais eventos de auditoria.",
+ "events": {
+ "request_pending": "Solicitação enviada",
+ "request_accepted": "Solicitação aceita",
+ "request_denied": "Solicitação negada",
+ "request_updated": "Solicitação atualizada",
+ "installment_created": "Parcela criada",
+ "installment_updated": "Parcela atualizada",
+ "installment_deleted": "Parcela excluída",
+ "allocation_recorded": "Pagamento alocado",
+ "allocation_removed": "Alocação de pagamento removida",
+ "payment_pending": "Pagamento pendente",
+ "payment_captured": "Pagamento capturado",
+ "payment_failed": "Falha no pagamento",
+ "payment_recorded": "Pagamento registrado",
+ "payment_updated": "Pagamento atualizado",
+ "resolution_refund": "Reembolso registrado",
+ "resolution_external_return": "Devolução externa registrada",
+ "resolution_retained": "Dinheiro retido",
+ "resolution_recorded": "Resolução de pagamento registrada",
+ "email_pending": "E-mail na fila",
+ "email_processing": "Tentativa de entrega do e-mail",
+ "email_sent": "E-mail entregue",
+ "email_failed": "Falha na entrega do e-mail",
+ "email_queued": "E-mail na fila",
+ "email_updated": "Entrega do e-mail atualizada",
+ "stay_amended": "Estadia aceita alterada"
+ }
+ }
}
}
diff --git a/apps/dashboard/src/locales/sr-Latn.json b/apps/dashboard/src/locales/sr-Latn.json
index 1d98ca8d..e19c3f53 100644
--- a/apps/dashboard/src/locales/sr-Latn.json
+++ b/apps/dashboard/src/locales/sr-Latn.json
@@ -144,7 +144,102 @@
"settingsSaved": "Podešavanja mehanizma za rezervacije su sačuvana",
"settingsSaveFailed": "Čuvanje podešavanja nije uspelo"
},
- "type": "Tip"
+ "type": "Tip",
+ "requestSettings": {
+ "autoConfirmDescription": "Automatska potvrda važi samo za trenutne rezervacije; zahtevi uvek traže odluku osoblja.",
+ "bookingMode": "Način rezervacije",
+ "backgroundLoadError": "Najnovija podešavanja nije bilo moguće proveriti. Učitana podešavanja su i dalje dostupna.",
+ "cardCollection": "Prikupljanje kartice",
+ "cardDescriptions": {
+ "disabled": "Gosti šalju zahtev bez dodavanja kartice.",
+ "optional": "Gosti biraju da li žele da sačuvaju karticu za plaćanja koja pokreće osoblje.",
+ "required": "Gosti moraju da sačuvaju karticu pre slanja zahteva. Nema automatske naplate."
+ },
+ "cardPolicies": {
+ "disabled": "Onemogućeno",
+ "optional": "Opciono",
+ "required": "Obavezno"
+ },
+ "conflictDescription": "Ova podešavanja su promenjena otkako ste otvorili stranicu. Vaš nacrt je sačuvan. Ponovo učitajte najnovija podešavanja pre nastavka uređivanja.",
+ "conflictTitle": "Sukob podešavanja",
+ "description": "Izaberite da li gosti rezervišu odmah ili šalju prijavu vašem timu na pregled.",
+ "engineToggle": "Omogući sistem za direktne rezervacije",
+ "invalidQuestions": "Ispravite obrazac za goste pre čuvanja ovih podešavanja.",
+ "loadError": "Nije moguće učitati podešavanja sistema za rezervacije.",
+ "loading": "Učitavanje podešavanja sistema za rezervacije",
+ "modeDescriptions": {
+ "instant": "Uspešna naplata prati postojeći tok trenutne rezervacije.",
+ "request": "Slanje pravi zahtev na čekanju bez potvrde rezervacije."
+ },
+ "modes": {
+ "instant": "Trenutna rezervacija",
+ "request": "Zahtev za rezervaciju"
+ },
+ "requiredCardWarning": "Dodajte Stripe javni ključ pre obaveznog prikupljanja kartice.",
+ "unsupportedCardWarning": "Konfigurisani provajder plaćanja ne podržava sačuvane kartice. Isključite prikupljanje kartica ili izaberite Stripe.",
+ "reloadLatest": "Ponovo učitaj najnovija podešavanja",
+ "reset": "Poništi promene",
+ "retry": "Pokušaj ponovo",
+ "save": "Sačuvaj promene",
+ "saveError": "Nije moguće sačuvati podešavanja sistema za rezervacije.",
+ "saving": "Čuvanje promena",
+ "stripeKey": "Stripe javni ključ",
+ "stripeKeyDescription": "Koristite javni ključ ovog objekta. Tajni Stripe ključevi ostaju na serveru.",
+ "stripeKeyPlaceholder": "pk_live_…",
+ "title": "Tok zahteva za rezervaciju",
+ "unsaved": "Nesačuvane promene",
+ "unsupportedPublishBlocked": "Koristite noviju verziju kontrolne table pre objavljivanja izmena ovog obrasca za goste."
+ },
+ "questions": {
+ "active": "Aktivno",
+ "activeQuestion": "Aktivno pitanje",
+ "add": "Dodaj pitanje",
+ "addOption": "Dodaj opciju",
+ "addTitle": "Dodaj pitanje",
+ "cancel": "Otkaži",
+ "cancelEditor": "Zatvori uređivač pitanja",
+ "count": "Pitanja: {{count}} / {{max}}",
+ "description": "Sastavite redosled prijave koju gosti popunjavaju pre slanja zahteva za rezervaciju.",
+ "disable": "Onemogući {{label}}",
+ "duplicateIds": "Identifikatori pitanja moraju biti jedinstveni da bi obrazac mogao da se uređuje.",
+ "edit": "Uredi {{label}}",
+ "editTitle": "Uredi pitanje",
+ "emptyDescription": "Dodajte samo podatke koji su timu potrebni za pregled zahteva.",
+ "emptyTitle": "Još nema pitanja u prijavi",
+ "enable": "Omogući {{label}}",
+ "idError": "Nije moguće napraviti jedinstveni identifikator. Pokušajte ponovo.",
+ "inactive": "Neaktivno",
+ "label": "Tekst pitanja",
+ "labelRequired": "Unesite tekst pitanja.",
+ "moveDown": "Pomeri {{label}} nadole",
+ "moveOptionDown": "Pomeri opciju {{number}} nadole",
+ "moveOptionUp": "Pomeri opciju {{number}} nagore",
+ "moveUp": "Pomeri {{label}} nagore",
+ "optionBlank": "Opcije ne smeju biti prazne.",
+ "optionDuplicate": "Opcije moraju biti jedinstvene.",
+ "optionLabel": "Opcija {{number}}",
+ "optionRequired": "Dodajte najmanje jednu opciju.",
+ "optional": "Opciono",
+ "options": "Opcije odgovora",
+ "remove": "Ukloni {{label}}",
+ "removeOption": "Ukloni opciju {{number}}",
+ "required": "Obavezno",
+ "requiredQuestion": "Obavezno pitanje",
+ "save": "Sačuvaj pitanje",
+ "title": "Plan obrasca za goste",
+ "type": "Vrsta pitanja",
+ "unsupportedActiveDescription": "Ova kontrolna tabla ne može bezbedno da izmeni niti ponovo objavi obrazac za goste dok je to pitanje aktivno.",
+ "unsupportedActiveTitle": "Aktivno je nepodržano pitanje",
+ "unsupportedType": "Nepodržano pitanje",
+ "types": {
+ "short_text": "Kratak tekst",
+ "long_text": "Dugačak tekst",
+ "single_select": "Jedan izbor",
+ "multi_select": "Višestruki izbor",
+ "yes_no": "Da / ne",
+ "date": "Datum"
+ }
+ }
},
"cashier": {
"amount": "Iznos",
@@ -1122,6 +1217,7 @@
"dashboard": "Kontrolna tabla",
"foliosBilling": "Foliji i naplata",
"frontDesk": "Recepcija",
+ "bookingRequests": "Zahtevi za rezervaciju",
"groups": "Grupe",
"guests": "Gosti",
"houseAccounts": "Interni računi",
@@ -1771,5 +1867,364 @@
"Bad Request": "Neispravan zahtev",
"Resource not found": "Resurs nije pronađen",
"Action not permitted": "Radnja nije dozvoljena"
+ },
+ "bookingRequests": {
+ "access": {
+ "title": "Pristup ograničen",
+ "description": "Potrebna vam je dozvola za čitanje rezervacija da biste mogli da pregledate zahteve za rezervaciju."
+ },
+ "property": {
+ "title": "Izaberite jedno svojstvo",
+ "description": "Zahtevi za rezervaciju se pregledaju jedan po objekat, tako da evidencija o gostima, novcu i odlukama ostaje u sigurnom opsegu."
+ },
+ "notFound": {
+ "title": "Zahtev nije pronađen",
+ "description": "Ovaj zahtev ne postoji u izabranom objektu ili više nije dostupan."
+ },
+ "common": {
+ "cancel": "Otkaži",
+ "loading": "Učitavanje zahteva za rezervaciju…",
+ "status": "Status",
+ "yes": "Da",
+ "no": "Ne",
+ "notProvided": "Nije navedeno",
+ "retry": "Pokušaj ponovo"
+ },
+ "actions": {
+ "accept": "Prihvatite zahtev",
+ "deny": "Odbij zahtev",
+ "charge": "Naplati karticu",
+ "record": "Evidentiraj uplatu"
+ },
+ "statuses": {
+ "pending": "Na čekanju",
+ "accepted": "Prihvaćeno",
+ "denied": "Odbijeno"
+ },
+ "tabs": {
+ "label": "Zahtevajte radni prostor",
+ "overview": "Pregled",
+ "payments": "Plaćanja i plan",
+ "messages": "Poruke",
+ "audit": "Revizija"
+ },
+ "queue": {
+ "title": "Zahtevi za rezervaciju",
+ "description": "Pregledajte zahteve gostiju, detalje o boravku i stanje novca pre nego što preduzmete eksplicitnu radnju.",
+ "filters": "Filteri zahteva za rezervaciju",
+ "guest": "Gost",
+ "card": "Kartica",
+ "stay": "Boravak",
+ "amount": "Traženi iznos",
+ "allStatuses": "Svi statusi",
+ "anyCard": "Bilo koje stanje kartice",
+ "cardSaved": "Kartica je sačuvana",
+ "noCard": "Nema kartice",
+ "arrivalFrom": "Dolazak iz",
+ "arrivalTo": "Dolazak u",
+ "sort": "Sortiraj po",
+ "sortOptions": {
+ "newest": "Najnovije prvo",
+ "arrival": "Datum dolaska",
+ "guest": "Ime gosta",
+ "amountDesc": "Najveći traženi iznos"
+ },
+ "clear": "Obriši",
+ "loading": "Učitavanje zahteva za rezervaciju…",
+ "loadError": "Učitavanje zahteva za rezervaciju nije uspelo. Pokušajte ponovo.",
+ "empty": "Nijedan zahtev za rezervaciju ne odgovara ovim filterima.",
+ "total_one": "{{count}} zahtev",
+ "total_other": "{{count}} zahteva",
+ "total": "{{count}} zahteva",
+ "previous": "Prethodno",
+ "next": "Sledeće"
+ },
+ "detail": {
+ "back": "Svi zahtevi za rezervaciju",
+ "reference": "{{reference}} · Poslato {{date}}",
+ "decisionActions": "Odluka",
+ "moneyActions": "Novac",
+ "decisionComplete": "Odluka završena",
+ "independence": "Odluke o rezervaciji i finansijske radnje su nezavisne. Primanje plaćanja nikada ne prihvata zahtev, a prihvatanje nikada ne pokreće plaćanje."
+ },
+ "amounts": {
+ "quoted": "Ponuda",
+ "submitted": "Podneto",
+ "current": "Trenutno",
+ "accepted": "Prihvaćeno",
+ "difference": "Razlika",
+ "captured": "Naplaćeno",
+ "returned": "Vraćeno",
+ "retained": "Zadržano"
+ },
+ "overview": {
+ "stay": "Zahtev za boravak",
+ "dates": "Datumi",
+ "occupancy": "Popunjenost",
+ "occupancyValue": "{{adults}} odrasli · {{children}} deca",
+ "roomType": "Tip sobe",
+ "ratePlan": "Tarifni plan",
+ "guest": "Gost i kontakt",
+ "email": "E-adresa",
+ "phone": "Telefon",
+ "specialRequests": "Posebni zahtevi",
+ "application": "Aplikacija",
+ "noQuestions": "Nisu dostavljena dodatna pitanja za prijavu.",
+ "priceComparison": "Poređenje cena",
+ "card": "Kartica u dosijeu",
+ "cardGeneric": "kartica",
+ "noCard": "Nema sačuvane kartice",
+ "cardSafety": "Prikazuju se samo bezbedni podaci o brendu kartice i poslednje četiri cifre. Osoblje mora pokrenuti svaku naplatu.",
+ "decision": "Zapisnik o odluci",
+ "priceSource": "Izvor cene"
+ },
+ "priceSources": {
+ "submitted": "Podneta ponuda",
+ "current": "Trenutna ponuda",
+ "custom": "Prilagođeni total"
+ },
+ "accept": {
+ "title": "Prihvatite zahtev za rezervaciju",
+ "priceChoice": "Prihvaćena cena",
+ "submitted": "Podneta ponuda",
+ "submittedDescription": "Koristite iznos koji je gost video kada je zahtev poslat.",
+ "current": "Trenutna ponuda",
+ "currentDescription": "Ponovo proverite dostupnost i izračunajte merodavnu trenutnu ponudu tokom prihvatanja.",
+ "custom": "Prilagođeni total",
+ "customDescription": "Postavite eksplicitni zbir i zabeležite zašto se razlikuje.",
+ "recheckedOnAccept": "Ponovo proveren prilikom prihvatanja",
+ "enterAmount": "Unesite iznos",
+ "customTotal": "Prilagođeni total",
+ "customReason": "Razlog za prilagođeni total",
+ "independence": "Prihvatanje kreira rezervaciju. Ne naplaćuje sačuvanu karticu niti zavisi od stanja plaćanja.",
+ "accepting": "Prihvatam…",
+ "error": "Zahtev nije mogao biti prihvaćen. Pregledajte trenutnu dostupnost i pokušajte ponovo."
+ },
+ "modifyStay": {
+ "action": "Izmeni boravak", "title": "Izmeni prihvaćeni boravak", "activeStay": "Aktivni boravak", "proposedStay": "Predloženi boravak", "originalRequest": "Prvobitni zahtev",
+ "arrivalDate": "Datum dolaska", "departureDate": "Datum odlaska", "invalidDates": "Odlazak mora biti posle dolaska, a oba datuma moraju biti ispravna.",
+ "checking": "Provera celog boravka i trenutne ponude…", "previewError": "Ovaj boravak trenutno nije moguće obračunati. Proverite dostupnost i pokušajte ponovo.",
+ "priceChoice": "Osnova cene za izmenjeni boravak", "prior": "Prethodna prihvaćena osnova", "priorDescription": "Datumi koji se preklapaju zadržavaju prihvaćene stavke; dodate noći koriste najbližu graničnu prihvaćenu cenu.",
+ "current": "Trenutna ponuda", "currentDescription": "Koristite merodavne cene koje su sada dostupne za ceo predloženi boravak.", "custom": "Prilagođeni total", "customDescription": "Postavite pozitivan operativni zbir i zabeležite razlog.",
+ "awaitingQuote": "Ponuda se čeka", "enterAmount": "Unesite iznos", "customTotal": "Prilagođeni total", "customReason": "Razlog za prilagođeni total",
+ "apply": "Primeni izmenu boravka", "applying": "Izmena je u toku…", "commitError": "Boravak nije mogao biti izmenjen. Pregledajte najnoviju dostupnost i ponudu."
+ },
+ "deny": {
+ "title": "Odbijte zahtev za rezervaciju",
+ "reason": "Razlog za odbijanje",
+ "unresolved": "{{amount}} ostaje nerešen",
+ "unresolvedDirection": "Refundirajte uplatu sačuvanom karticom, zabeležite eksterni povratak ili zadržite iznos sa razlogom pre nego što odbijete.",
+ "resolveFirst": "Prvo reši novac",
+ "confirm": "Potvrdite odbijanje",
+ "denying": "Odbijanje…",
+ "error": "Zahtev se nije mogao odbiti. Pregledajte stanje novca i pokušajte ponovo.",
+ "moneyLoading": "Provera stanja plaćanja pre odbijanja…",
+ "moneyLoadError": "Stanje plaćanja nije moguće proveriti. Odbijanje ostaje blokirano.",
+ "retryMoney": "Ponovo proveri stanje plaćanja"
+ },
+ "validation": {
+ "positiveAmount": "Unesite iznos veći od nule.",
+ "required": "Unesite iznos.",
+ "format": "Unesite važeći decimalni iznos.",
+ "positive": "Unesite iznos veći od nule.",
+ "precision": "Koristite samo decimalna mesta koja podržava ova valuta.",
+ "unsupportedCurrency": "Glavna knjiga ne podržava decimalna mesta ove valute.",
+ "maximum": "Unesite iznos unutar dozvoljenog maksimuma."
+ },
+ "payments": {
+ "requestSummary": "Sažetak plaćanja za zahtev",
+ "independence": "Odluke o rezervaciji ne zavise od stanja plaćanja.",
+ "plan": "Plan plaćanja",
+ "noAutomatic": "Ništa se ne naplaćuje automatski. Prekretnice su podsetnici za akcije koje je pokrenulo osoblje.",
+ "noAutomaticTitle": "Ništa se ne naplaćuje automatski.",
+ "noAutomaticDescription": "Prekretnice su podsetnici za akcije koje je pokrenulo osoblje.",
+ "addInstallment": "Dodaj ratu",
+ "editInstallment": "Uredi rate",
+ "installmentLabel": "Oznaka rate",
+ "amountType": "Vrsta iznosa",
+ "fixedAmount": "Fiksni iznos",
+ "percentage": "Procenat",
+ "milestone": "Rok dospeća",
+ "dueDate": "Datum dospeća",
+ "saveInstallment": "Sačuvaj ratu",
+ "installmentError": "Rata nije mogla da se sačuva.",
+ "noInstallments": "Još nema rata plana plaćanja.",
+ "allocated": "{{allocated}} od {{total}} dodeljeno",
+ "editLabel": "Izmeni {{label}}",
+ "deleteLabel": "Obriši {{label}}",
+ "removeRemainingAmount": "Ukloni preostali iznos — {{amount}} ostaje plaćeno",
+ "allocateLabel": "Dodeli uplatu na {{label}}",
+ "allocateTo": "Dodeli uplatu na {{label}}",
+ "movement": "Naplaćena uplata",
+ "allocate": "Dodeli",
+ "allocationError": "Uplata nije mogla biti dodeljena.",
+ "availableForAllocation": "{{available}} dostupno · {{allocated}} dodeljeno · {{method}}",
+ "allocationAvailability": "{{available}} dostupno · {{allocated}} dodeljeno",
+ "moveUp": "Pomeri {{label}} gore",
+ "moveDown": "Pomeri {{label}} dole",
+ "reorderError": "Redosled rata nije moguće sačuvati. Pokušajte ponovo.",
+ "movements": "Prometi plaćanja",
+ "noMovements": "Još nema prometa plaćanja.",
+ "savedCardProvenance": "Sačuvana kartica · {{brand}} •••• {{lastFour}}",
+ "externalProvenance": "Eksterno · {{method}} · {{reference}}",
+ "loadError": "Plan plaćanja i istorija kretanja nisu mogli da se učitaju.",
+ "folioSummary": "Operativni folio sažetak",
+ "acceptedDeal": "Prihvaćen ukupan iznos",
+ "activeStayTotal": "Ukupno za aktivni boravak",
+ "folioCharges": "Stavke folija",
+ "folioPayments": "Folio plaćanja",
+ "balanceDue": "Dospelo stanje",
+ "folioError": "Povezani rezime folija nije mogao da se učita."
+ },
+ "installmentStatuses": {
+ "unpaid": "Neplaćeno",
+ "partial": "Delimično",
+ "paid": "Plaćeno"
+ },
+ "milestones": {
+ "manual": "Ručno praćenje",
+ "arrival": "Dospeva po dolasku",
+ "checkout": "Dospeva na odlasku",
+ "date": "Određeni datum",
+ "dateValue": "Dospeva {{date}}"
+ },
+ "methods": {
+ "credit_card": "kreditna kartica",
+ "debit_card": "debitna kartica",
+ "cash": "gotovina",
+ "bank_transfer": "bankovni transfer",
+ "pix": "PIX",
+ "other": "drugo"
+ },
+ "paymentStatuses": {
+ "pending": "Na čekanju",
+ "authorized": "Autorizovano",
+ "captured": "Naplaćeno",
+ "settled": "Izmireno",
+ "refunded": "Vraćeno",
+ "partially_refunded": "Delimično refundirano",
+ "failed": "Nije uspelo",
+ "voided": "Poništeno"
+ },
+ "paymentActions": {
+ "amount": "Iznos",
+ "method": "Način plaćanja",
+ "processedAt": "Obrađeno u",
+ "provider": "Provajder (opciono)",
+ "reference": "Referenca",
+ "notes": "Beleške (opciono)",
+ "saving": "Čuvanje…",
+ "error": "Finansijska radnja nije mogla da se završi. Podaci koje ste uneli su i dalje ovde.",
+ "paymentRequired": "Prvo izaberite kretanje plaćanja.",
+ "charge": {
+ "title": "Naplatite sačuvanu karticu",
+ "action": "Naplatite sačuvanu karticu",
+ "description": "Ovo je eksplicitna naplata koju pokreće osoblje. Ne prihvata zahtev."
+ },
+ "external": {
+ "title": "Zabeležite eksterno plaćanje",
+ "action": "Zabeležite eksterno plaćanje",
+ "description": "Snimite novac koji je već prikupljen van mrežnog prolaza sačuvane kartice."
+ },
+ "refund": {
+ "title": "Povraćaj uplate sa sačuvane kartice",
+ "action": "Vrati novac",
+ "description": "Vratite deo ili celu uplatu preko platnog servisa."
+ },
+ "external_return": {
+ "title": "Zabeležite eksterni povraćaj",
+ "action": "Zabeleži povraćaj",
+ "description": "Zabeležite da je novac naplaćen van sistema vraćen."
+ },
+ "retain": {
+ "title": "Zadržite novac",
+ "action": "Zadržite novac",
+ "open": "Zadrži s razlogom",
+ "description": "Rešite ovaj iznos kao zadržan pre odbijanja. Poslovni razlog je obavezan.",
+ "reason": "Razlog za zadržavanje novca"
+ }
+ },
+ "messages": {
+ "title": "Isporuke poruka",
+ "description": "Transakciona e-pošta je posledica radnji osoblja. Neuspeh isporuke nikada ne poništava odluku ili kretanje novca.",
+ "loading": "Učitavanje istorije poruka…",
+ "loadError": "Nije moguće učitati istoriju poruka.",
+ "empty": "Još uvek nije zabeležena nijedna transakcijska poruka.",
+ "retry": "Ponovite isporuku",
+ "retryError": "Neuspelu isporuku nije moguće ponovo pokušati.",
+ "attempts_one": "{{count}} pokušaj",
+ "attempts_other": "{{count}} pokušaja",
+ "attempts": "{{count}} pokušaja",
+ "actorNote": "Ručni ponovni pokušaj se snima sa identitetom vašeg osoblja."
+ },
+ "messageKinds": {
+ "receipt": "Potvrda zahteva",
+ "accepted": "Prihvatanje",
+ "denied": "Odbijanje",
+ "payment": "Plaćanje",
+ "refund": "Povraćaj",
+ "failure": "Neuspešno plaćanje"
+ },
+ "messageStatuses": {
+ "pending": "Na čekanju",
+ "processing": "Obrada",
+ "sent": "Poslato",
+ "failed": "Nije uspelo"
+ },
+ "audit": {
+ "title": "Poslovni vremenski okvir",
+ "description": "Bezbedan operativni prikaz izveden iz evidencije zahteva, kretanja, rezolucije i isporuke koja je dostupna osoblju.",
+ "submitted": "Zahtev je podnet",
+ "submittedDescription": "Prijava gosta i snimak podnete ponude su snimljeni.",
+ "accepted": "Zahtev je prihvaćen",
+ "acceptedDescription": "Prihvaćeno na {{amount}} koristeći {{source}}.",
+ "denied": "Zahtev odbijen",
+ "deniedDescription": "Zahtev je odbijen nakon što je stanje novca rešeno.",
+ "paymentCaptured": "Plaćanje je naplaćeno",
+ "paymentFailed": "Plaćanje nije uspelo",
+ "paymentReturned": "Uplata vraćena",
+ "paymentDescription": "{{amount}} · {{method}}",
+ "resolutionDescription": "{{amount}} rešeno",
+ "resolutions": {
+ "refund": "Povraćaj kartične uplate je zabeležen",
+ "external_return": "Eksterni povraćaj je zabeležen",
+ "retained": "Novac zadržan"
+ },
+ "message": "{{kind}} poruka",
+ "messageDescription": "Status isporuke: {{status}}",
+ "actor": "Izvršio/la: {{actor}}",
+ "loadError": "Istoriju revizije nije moguće učitati.",
+ "empty": "Nema evidentiranih revizijskih događaja.",
+ "loadMore": "Učitaj još",
+ "loadingMore": "Učitavanje dodatnih događaja…",
+ "loadMoreError": "Dodatne revizijske događaje nije moguće učitati.",
+ "events": {
+ "request_pending": "Zahtev poslat",
+ "request_accepted": "Zahtev prihvaćen",
+ "request_denied": "Zahtev odbijen",
+ "request_updated": "Zahtev ažuriran",
+ "installment_created": "Rata kreirana",
+ "installment_updated": "Rata ažurirana",
+ "installment_deleted": "Rata obrisana",
+ "allocation_recorded": "Plaćanje dodeljeno",
+ "allocation_removed": "Dodela plaćanja uklonjena",
+ "payment_pending": "Plaćanje na čekanju",
+ "payment_captured": "Plaćanje naplaćeno",
+ "payment_failed": "Plaćanje nije uspelo",
+ "payment_recorded": "Plaćanje evidentirano",
+ "payment_updated": "Plaćanje ažurirano",
+ "resolution_refund": "Povraćaj sredstava evidentiran",
+ "resolution_external_return": "Eksterni povraćaj evidentiran",
+ "resolution_retained": "Novac zadržan",
+ "resolution_recorded": "Rešenje plaćanja evidentirano",
+ "email_pending": "E-pošta stavljena na čekanje",
+ "email_processing": "Pokušana dostava e-pošte",
+ "email_sent": "E-pošta dostavljena",
+ "email_failed": "Dostava e-pošte nije uspela",
+ "email_queued": "E-pošta stavljena na čekanje",
+ "email_updated": "Dostava e-pošte ažurirana",
+ "stay_amended": "Prihvaćeni boravak izmenjen"
+ }
+ }
}
}
diff --git a/apps/dashboard/src/pages/BookingRequests.test.tsx b/apps/dashboard/src/pages/BookingRequests.test.tsx
new file mode 100644
index 00000000..296c5fd3
--- /dev/null
+++ b/apps/dashboard/src/pages/BookingRequests.test.tsx
@@ -0,0 +1,1324 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ToastProvider } from '../components/ui/Toast';
+import { localDateTimeInputValue } from '../components/booking-requests/PaymentActionModal';
+import BookingRequests from './BookingRequests';
+import de from '../locales/de.json';
+import en from '../locales/en.json';
+import es from '../locales/es.json';
+import fr from '../locales/fr.json';
+import hr from '../locales/hr.json';
+import itMessages from '../locales/it.json';
+import ptBR from '../locales/pt-BR.json';
+import srLatn from '../locales/sr-Latn.json';
+
+const context = vi.hoisted(() => ({
+ propertyId: 'property-1' as string | null,
+ read: true,
+ write: true,
+}));
+
+vi.mock('../context/PropertyContext', () => ({
+ useProperty: () => ({
+ propertyId: context.propertyId,
+ currencyCode: 'EUR',
+ isPortfolioMode: false,
+ }),
+}));
+
+vi.mock('../context/AuthContext', () => ({
+ useAuth: () => ({
+ hasPermission: (permission: string) =>
+ permission === 'reservations.read' ? context.read : context.write,
+ }),
+}));
+
+vi.mock('../lib/api', () => ({
+ api: {
+ get: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ },
+}));
+
+import { api } from '../lib/api';
+
+const REQUEST_ID = 'request-1';
+const PAYMENT_ID = 'payment-1';
+const EXTERNAL_PAYMENT_ID = 'payment-2';
+
+const requestListItem = {
+ id: REQUEST_ID,
+ propertyId: 'property-1',
+ status: 'pending',
+ arrivalDate: '2026-09-10',
+ departureDate: '2026-09-12',
+ roomTypeId: 'room-type-1',
+ ratePlanId: 'rate-plan-1',
+ adults: 2,
+ children: 0,
+ guestFirstName: 'Ada',
+ guestLastName: 'Lovelace',
+ guestEmail: 'ada@example.com',
+ hasCard: true,
+ acceptedPriceSource: null,
+ acceptedTotal: null,
+ submittedTotal: '640.00',
+ currencyCode: 'EUR',
+ acceptedReservationId: null,
+ createdAt: '2026-08-24T10:00:00.000Z',
+ updatedAt: '2026-08-24T10:00:00.000Z',
+};
+
+const requestDetail = {
+ ...requestListItem,
+ guestPhone: '+34 600 000 000',
+ specialRequests: 'A quiet room, please.',
+ serviceIds: [],
+ formSnapshot: [
+ {
+ id: 'question-1',
+ label: 'Expected arrival time',
+ type: 'short_text',
+ order: 0,
+ isActive: true,
+ isRequired: true,
+ },
+ ],
+ applicationAnswers: { 'question-1': 'After 18:00' },
+ submittedQuoteSnapshot: {
+ currencyCode: 'EUR',
+ grandTotal: '640.00',
+ roomTotal: '580.00',
+ taxTotal: '60.00',
+ lineItems: [],
+ },
+ currentQuoteSnapshot: {
+ currencyCode: 'EUR',
+ grandTotal: '670.00',
+ roomTotal: '610.00',
+ taxTotal: '60.00',
+ lineItems: [],
+ },
+ currencyCode: 'EUR',
+ card: { brand: 'visa', lastFour: '4242' },
+ customPriceReason: null,
+ acceptedFolioId: null,
+ decidedBy: null,
+ decidedAt: null,
+ denialReason: null,
+ operationalReservation: null,
+};
+
+const stripePayment = {
+ id: PAYMENT_ID,
+ propertyId: 'property-1',
+ bookingRequestId: REQUEST_ID,
+ folioId: null,
+ method: 'credit_card',
+ status: 'captured',
+ amount: '192.00',
+ netCapturedAmount: '192.00',
+ allocatedAmount: '50.00',
+ reservedResolutionAmount: '0.00',
+ availableToAllocate: '142.00',
+ availableToResolve: '192.00',
+ unresolvedAmount: '192.00',
+ returnedAmount: '0.00',
+ retainedAmount: '0.00',
+ availableAmount: '142.00',
+ currencyCode: 'EUR',
+ source: 'saved_card',
+ gatewayProvider: 'stripe',
+ reference: null,
+ cardLastFour: '4242',
+ cardBrand: 'visa',
+ originalPaymentId: null,
+ notes: 'Staff-initiated Booking Request saved-card charge captured',
+ processedAt: '2026-08-25T09:00:00.000Z',
+ createdAt: '2026-08-25T09:00:00.000Z',
+ updatedAt: '2026-08-25T09:00:00.000Z',
+};
+
+const externalPayment = {
+ ...stripePayment,
+ id: EXTERNAL_PAYMENT_ID,
+ method: 'bank_transfer',
+ amount: '100.00',
+ netCapturedAmount: '100.00',
+ allocatedAmount: '0.00',
+ reservedResolutionAmount: '0.00',
+ availableToAllocate: '100.00',
+ availableToResolve: '100.00',
+ unresolvedAmount: '100.00',
+ returnedAmount: '0.00',
+ retainedAmount: '0.00',
+ availableAmount: '100.00',
+ source: 'external',
+ gatewayProvider: 'stripe',
+ reference: 'BANK-42',
+ cardLastFour: null,
+ cardBrand: null,
+ notes: 'Deposit received',
+};
+
+const paymentsEmpty = { movements: [], allocations: [], resolutions: [] };
+
+function mockApi(overrides?: {
+ list?: unknown;
+ detail?: unknown;
+ payments?: unknown;
+ installments?: unknown;
+ emails?: unknown;
+ folio?: unknown | (() => unknown);
+ preview?: unknown;
+ amendmentPreview?: unknown;
+ audit?: unknown | ((cursor: string | null) => unknown);
+}) {
+ vi.mocked(api.get).mockImplementation((url: string, config?: { params?: Record }) => {
+ if (url === '/v1/booking-requests') {
+ return Promise.resolve({
+ data: overrides?.list ?? {
+ data: [requestListItem],
+ total: 1,
+ page: 1,
+ limit: 20,
+ hasMore: false,
+ },
+ } as never);
+ }
+ if (url === `/v1/booking-requests/${REQUEST_ID}`) {
+ return Promise.resolve({ data: overrides?.detail ?? requestDetail } as never);
+ }
+ if (url === `/v1/booking-requests/${REQUEST_ID}/acceptance-preview`) {
+ return Promise.resolve({ data: overrides?.preview ?? {
+ requestId: REQUEST_ID,
+ submittedTotal: '640.00',
+ currentTotal: '670.00',
+ currencyCode: 'EUR',
+ previewVersion: 1,
+ previewToken: 'v1:preview-token',
+ } } as never);
+ }
+ if (url === `/v1/booking-requests/${REQUEST_ID}/stay-amendment-preview`) {
+ return Promise.resolve({ data: overrides?.amendmentPreview ?? {
+ requestId: REQUEST_ID,
+ reservationId: 'reservation-1',
+ previousArrivalDate: '2026-09-10',
+ previousDepartureDate: '2026-09-12',
+ previousTotal: '640.00',
+ arrivalDate: String(config?.params?.arrivalDate ?? '2026-09-10'),
+ departureDate: String(config?.params?.departureDate ?? '2026-09-13'),
+ priorTotal: '960.00',
+ currentTotal: '990.00',
+ currencyCode: 'EUR',
+ previewVersion: 1,
+ previewToken: `v1:${'a'.repeat(64)}`,
+ } } as never);
+ }
+ if (url === `/v1/booking-requests/${REQUEST_ID}/audit-history`) {
+ const audit = typeof overrides?.audit === 'function'
+ ? overrides.audit(typeof config?.params?.cursor === 'string' ? config.params.cursor : null)
+ : overrides?.audit ?? [];
+ return Promise.resolve({
+ data: Array.isArray(audit) ? { data: audit, nextCursor: null } : audit,
+ } as never);
+ }
+ if (url === `/v1/booking-requests/${REQUEST_ID}/payments`) {
+ if (overrides?.payments instanceof Error) return Promise.reject(overrides.payments);
+ return Promise.resolve({ data: overrides?.payments ?? paymentsEmpty } as never);
+ }
+ if (url === `/v1/booking-requests/${REQUEST_ID}/installments`) {
+ return Promise.resolve({ data: overrides?.installments ?? [] } as never);
+ }
+ if (url === `/v1/booking-requests/${REQUEST_ID}/emails`) {
+ return Promise.resolve({ data: overrides?.emails ?? [] } as never);
+ }
+ if (url === '/v1/folios/folio-1') {
+ const folio = typeof overrides?.folio === 'function'
+ ? overrides.folio()
+ : overrides?.folio ?? {};
+ return Promise.resolve({ data: folio } as never);
+ }
+ return Promise.resolve({ data: [] } as never);
+ });
+}
+
+function renderAt(path = '/booking-requests') {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0 },
+ mutations: { retry: false },
+ },
+ });
+ const view = render(
+
+