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 @@ NestJS PostgreSQL Apache 2.0 License - 1635 Tests Passing 12 AI Agents + 2222 Tests Passing 12 AI Agents

@@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire | OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) | | XML Processing | fast-xml-parser | Booking.com OTA XML protocol | | Package Manager | pnpm workspaces | Monorepo management | -| Testing | Vitest (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 ( + +