From 7d463210deb9bf54fc9cf7213cb9070504be8300 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 18:21:22 +0000 Subject: [PATCH 01/16] Add opt-in booking-requests package with core seams and UI gates Introduce @telivityhaip/booking-requests as a deploy-time optional module (HAIP_BOOKING_REQUESTS=true) with separate migrations, Stripe handler delegation, and dashboard/booking widget feature flags. Core instant booking paths stay unchanged when the flag is off. Includes booking request API/controllers, schema split (0022-0032), email transport hardening, webhook logicalEventId dedup, and CI release-gate job. Co-authored-by: telivity-otaip --- .env.example | 7 + .github/workflows/release.yml | 64 +- apps/api/package.json | 2 + apps/api/src/app.module.ts | 9 + .../accepted-reservation-service.ts | 49 + .../common/crypto/confirmation-number.spec.ts | 13 + .../src/common/crypto/confirmation-number.ts | 21 + .../accepted-pricing-lock.postgres.spec.ts | 816 +++++ .../database/accepted-pricing-lock.spec.ts | 92 + .../common/database/accepted-pricing-lock.ts | 35 + .../src/common/date/property-business-date.ts | 18 + .../validation/is-money-string.validator.ts | 7 +- apps/api/src/database/database.module.ts | 12 +- .../guest-comms/email-provider.interface.ts | 13 +- .../agent/guest-comms/email.service.spec.ts | 68 + .../agent/guest-comms/email.service.ts | 13 +- .../providers/bounded-email-transport.ts | 63 + .../providers/console-email.provider.ts | 2 +- .../providers/mailgun-email.provider.ts | 50 +- .../providers/mailgun-ses.provider.spec.ts | 117 + .../providers/sendgrid-email.provider.ts | 53 +- .../providers/ses-email.provider.ts | 56 +- .../providers/smtp-email.provider.spec.ts | 162 + .../providers/smtp-email.provider.ts | 109 +- .../ancillary-accepted-pricing.spec.ts | 975 ++++++ .../modules/ancillary/ancillary.controller.ts | 4 +- .../ancillary/ancillary.service.spec.ts | 34 +- .../modules/ancillary/ancillary.service.ts | 668 +++-- .../ancillary/reservation-service-event.ts | 23 + .../booking-engine-admin.controller.ts | 30 +- .../booking-engine-config.service.ts | 251 +- .../booking-engine/booking-engine.module.ts | 2 +- .../booking-engine.service.spec.ts | 161 +- .../booking-engine/booking-engine.service.ts | 127 +- .../booking-form-questions.spec.ts | 687 +++++ .../booking-engine/booking-form-questions.ts | 213 ++ .../booking-engine/dto/be-admin.dto.ts | 88 + .../dto/be-create-booking.dto.ts | 2 + .../booking-engine/dto/be-quote.dto.ts | 3 +- ...king-request-allocation-reconciler.spec.ts | 131 + .../booking-request-allocation-reconciler.ts | 185 ++ .../booking-request-amendment-pricing.spec.ts | 231 ++ .../booking-request-amendment-pricing.ts | 196 ++ .../booking-request-amendment.spec.ts | 796 +++++ .../booking-request-authorization.spec.ts | 152 + ...king-request-consequence-worker.service.ts | 62 + .../booking-request-date.validator.ts | 72 + .../booking-request/booking-request-db.ts | 3 + .../booking-request-decision.spec.ts | 2258 ++++++++++++++ ...ng-request-default-flow-regression.spec.ts | 767 +++++ .../booking-request-email.templates.ts | 118 + .../booking-request-mailer.service.ts | 605 ++++ .../booking-request-mailer.spec.ts | 721 +++++ .../booking-request-money.spec.ts | 144 + .../booking-request/booking-request-money.ts | 328 ++ .../booking-request-payment-consequence.ts | 163 + .../booking-request-payment-ledger.ts | 116 + .../booking-request-payment.db.spec.ts | 864 ++++++ .../booking-request-payment.service.ts | 2356 +++++++++++++++ .../booking-request-payment.spec.ts | 2650 +++++++++++++++++ .../booking-request-pricing.spec.ts | 182 ++ .../booking-request-pricing.ts | 276 ++ .../booking-request-public.controller.ts | 43 + .../booking-request-state.spec.ts | 22 + .../booking-request/booking-request-state.ts | 18 + .../booking-request-stripe.handler.ts | 923 ++++++ .../booking-request-submission.spec.ts | 1063 +++++++ .../booking-request.controller.ts | 337 +++ .../booking-request.e2e-spec.ts | 979 ++++++ .../booking-request/booking-request.module.ts | 57 + .../booking-request.service.ts | 2302 ++++++++++++++ .../dto/accept-booking-request.dto.ts | 27 + .../dto/amend-booking-request-stay.dto.ts | 50 + .../dto/booking-request-payment.dto.ts | 184 ++ .../dto/booking-request-response.dto.ts | 288 ++ .../dto/create-request-card-setup.dto.ts | 27 + .../dto/deny-booking-request.dto.ts | 10 + .../dto/list-booking-request-audit.dto.ts | 23 + .../dto/list-booking-requests.dto.ts | 92 + .../dto/submit-booking-request.dto.ts | 136 + .../connect/connect-booking.service.ts | 15 +- .../connect/connect-events.service.spec.ts | 77 +- .../modules/connect/connect-events.service.ts | 33 +- .../modules/folio/dto/create-charge.dto.ts | 16 +- .../folio/folio-charge-validation.spec.ts | 45 +- .../folio/folio-create-charge-http.spec.ts | 77 + .../folio/folio-routing.service.spec.ts | 56 +- .../modules/folio/folio-routing.service.ts | 31 + .../folio/folio-stay-amendment.spec.ts | 866 ++++++ .../src/modules/folio/folio.service.spec.ts | 558 +++- apps/api/src/modules/folio/folio.service.ts | 945 +++++- apps/api/src/modules/guest/guest.service.ts | 5 +- .../night-audit/night-audit.service.spec.ts | 303 +- .../night-audit/night-audit.service.ts | 113 +- ...g-request-stripe-handler.interface.spec.ts | 23 + ...ooking-request-stripe-handler.interface.ts | 67 + .../interfaces/payment-gateway.interface.ts | 11 + .../saved-payment-method-gateway.interface.ts | 54 + .../mock-saved-payment-method.gateway.spec.ts | 203 ++ .../mock-saved-payment-method.gateway.ts | 133 + .../modules/payment/payment-ledger.spec.ts | 9 +- .../api/src/modules/payment/payment-ledger.ts | 26 + .../payment/payment-legacy-seam.spec.ts | 133 + .../api/src/modules/payment/payment.module.ts | 29 +- .../modules/payment/payment.service.spec.ts | 126 +- .../src/modules/payment/payment.service.ts | 172 +- .../payment/stripe-financial-state.spec.ts | 114 + .../modules/payment/stripe-financial-state.ts | 160 + .../modules/payment/stripe-gateway.spec.ts | 99 +- .../api/src/modules/payment/stripe-gateway.ts | 92 +- ...tripe-saved-payment-method.gateway.spec.ts | 491 +++ .../stripe-saved-payment-method.gateway.ts | 293 ++ .../payment/stripe-webhook.controller.ts | 103 +- ...nsupported-saved-payment-method.gateway.ts | 43 + apps/api/src/modules/policy/policy.service.ts | 11 +- .../modules/rate-plan/rate-plan.service.ts | 34 +- .../modules/reports/reports.service.spec.ts | 25 +- .../src/modules/reports/reports.service.ts | 5 +- .../reservation/availability.service.spec.ts | 31 + .../reservation/availability.service.ts | 75 +- .../reservation/dto/modify-reservation.dto.ts | 7 +- .../reservation-assert-sellable.spec.ts | 320 +- .../reservation/reservation.controller.ts | 5 +- .../reservation/reservation.service.ts | 315 +- apps/api/src/modules/tax/tax.service.ts | 17 +- .../webhook/webhook-delivery.service.spec.ts | 141 +- .../webhook/webhook-delivery.service.ts | 38 +- .../modules/webhook/webhook.service.spec.ts | 58 + .../src/modules/webhook/webhook.service.ts | 23 +- apps/booking/src/App.tsx | 12 + apps/booking/src/api/client.ts | 21 + apps/booking/src/api/types.ts | 69 + apps/booking/src/components/Button.tsx | 2 +- .../src/components/ConfiguredQuestion.tsx | 165 + apps/booking/src/components/Field.tsx | 13 +- apps/booking/src/components/Layout.tsx | 40 +- apps/booking/src/components/MockSetupForm.tsx | 50 + .../src/components/RequestStayDocket.tsx | 101 + .../src/components/StripeSetupForm.tsx | 109 + .../src/context/BookingFlowContext.tsx | 285 +- apps/booking/src/index.css | 11 + .../booking/src/lib/bookingRequestsFeature.ts | 4 + apps/booking/src/lib/format.test.ts | 14 + apps/booking/src/lib/format.ts | 34 + apps/booking/src/lib/requestCardConsent.ts | 5 + apps/booking/src/lib/requestPayload.ts | 53 + apps/booking/src/mount.tsx | 29 +- apps/booking/src/pages/Extras.tsx | 10 +- .../src/pages/RequestApplication.test.tsx | 740 +++++ apps/booking/src/pages/RequestApplication.tsx | 319 ++ .../src/pages/RequestFlow.e2e.test.tsx | 272 ++ .../booking/src/pages/RequestPayment.test.tsx | 607 ++++ apps/booking/src/pages/RequestPayment.tsx | 450 +++ apps/booking/src/pages/RequestReceived.tsx | 53 + apps/booking/vitest.config.ts | 3 + apps/dashboard/src/App.tsx | 5 + .../admin/BookingEngineSettings.tsx | 843 ++++-- .../admin/BookingQuestionBuilder.test.tsx | 846 ++++++ .../admin/BookingQuestionBuilder.tsx | 585 ++++ .../admin/booking-request-config.ts | 79 + .../booking-requests/AcceptRequestModal.tsx | 219 ++ .../booking-requests/DenyRequestModal.tsx | 104 + .../booking-requests/ModifyStayModal.test.tsx | 157 + .../booking-requests/ModifyStayModal.tsx | 306 ++ .../booking-requests/PaymentActionModal.tsx | 202 ++ .../booking-requests/RequestAudit.tsx | 165 + .../booking-requests/RequestMessages.tsx | 117 + .../booking-requests/RequestOverview.tsx | 209 ++ .../booking-requests/RequestPayments.tsx | 506 ++++ .../bookingRequestPaymentsQuery.ts | 29 + .../booking-requests/moneyInput.test.ts | 29 + .../components/booking-requests/moneyInput.ts | 67 + .../components/booking-requests/queryKeys.ts | 54 + .../src/components/booking-requests/types.ts | 247 ++ .../src/components/layout/Sidebar.test.tsx | 54 +- .../src/components/layout/Sidebar.tsx | 4 + .../src/components/ui/Modal.test.tsx | 7 +- apps/dashboard/src/components/ui/Modal.tsx | 68 +- .../src/hooks/useRealtimeInvalidation.test.ts | 107 + .../src/hooks/useRealtimeInvalidation.ts | 167 +- .../src/lib/bookingRequestsFeature.ts | 4 + apps/dashboard/src/locales/de.json | 506 ++++ apps/dashboard/src/locales/en.json | 474 ++- apps/dashboard/src/locales/es.json | 457 ++- apps/dashboard/src/locales/fr.json | 457 ++- apps/dashboard/src/locales/hr.json | 457 ++- apps/dashboard/src/locales/it.json | 457 ++- apps/dashboard/src/locales/pt-BR.json | 457 ++- apps/dashboard/src/locales/sr-Latn.json | 457 ++- .../src/pages/BookingRequests.test.tsx | 1324 ++++++++ apps/dashboard/src/pages/BookingRequests.tsx | 413 +++ apps/dashboard/vitest.config.ts | 3 + package.json | 1 + packages/booking-requests/README.md | 16 + packages/booking-requests/package.json | 66 + .../booking-requests/src/database/migrate.ts | 144 + .../migrations/0022_booking_requests.sql | 250 ++ .../0023_booking_request_accepted_pricing.sql | 141 + ...0024_booking_request_payment_integrity.sql | 116 + ...025_booking_request_financial_recovery.sql | 314 ++ .../0026_booking_request_email_recovery.sql | 30 + ...027_booking_request_email_retry_policy.sql | 23 + ...8_booking_request_audit_timeline_index.sql | 3 + ...029_booking_request_audit_relationship.sql | 48 + .../0030_booking_request_stay_amendments.sql | 108 + .../0031_booking_request_amendment_ledger.sql | 47 + .../0032_booking_request_remediation.sql | 116 + .../src/database/schema/booking-request.ts | 425 +++ .../src/database/schema/index.ts | 1 + packages/booking-requests/src/enabled.ts | 4 + packages/booking-requests/src/index.ts | 3 + .../booking-requests/src/register.module.ts | 27 + packages/booking-requests/tsconfig.json | 8 + packages/booking-requests/tsup.config.ts | 12 + packages/booking-requests/vitest.config.ts | 7 + .../0022_webhook_logical_event_id.sql | 6 + packages/database/src/push-schema.ts | 6 + packages/database/src/schema/audit.ts | 13 +- .../database/src/schema/booking-engine.ts | 48 + packages/database/src/schema/connect.ts | 20 +- packages/database/src/schema/folio.ts | 46 +- packages/database/src/schema/index.ts | 12 + packages/database/src/schema/reservation.ts | 54 +- packages/shared/src/index.spec.ts | 32 +- packages/shared/src/index.ts | 45 +- .../shared/src/sync-test-count-script.spec.js | 58 + pnpm-lock.yaml | 55 + scripts/sync-test-count.mjs | 128 +- 228 files changed, 47107 insertions(+), 1141 deletions(-) create mode 100644 apps/api/src/common/accepted-pricing/accepted-reservation-service.ts create mode 100644 apps/api/src/common/crypto/confirmation-number.spec.ts create mode 100644 apps/api/src/common/crypto/confirmation-number.ts create mode 100644 apps/api/src/common/database/accepted-pricing-lock.postgres.spec.ts create mode 100644 apps/api/src/common/database/accepted-pricing-lock.spec.ts create mode 100644 apps/api/src/common/database/accepted-pricing-lock.ts create mode 100644 apps/api/src/common/date/property-business-date.ts create mode 100644 apps/api/src/modules/agent/guest-comms/providers/bounded-email-transport.ts create mode 100644 apps/api/src/modules/agent/guest-comms/providers/smtp-email.provider.spec.ts create mode 100644 apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts create mode 100644 apps/api/src/modules/ancillary/reservation-service-event.ts create mode 100644 apps/api/src/modules/booking-engine/booking-form-questions.spec.ts create mode 100644 apps/api/src/modules/booking-engine/booking-form-questions.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-allocation-reconciler.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-allocation-reconciler.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-amendment-pricing.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-amendment-pricing.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-amendment.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-authorization.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-consequence-worker.service.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-date.validator.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-db.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-decision.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-default-flow-regression.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-email.templates.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-mailer.service.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-mailer.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-money.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-money.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-payment-consequence.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-payment-ledger.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-payment.db.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-payment.service.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-payment.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-pricing.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-pricing.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-public.controller.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-state.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-state.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-stripe.handler.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-submission.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request.controller.ts create mode 100644 apps/api/src/modules/booking-request/booking-request.e2e-spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request.module.ts create mode 100644 apps/api/src/modules/booking-request/booking-request.service.ts create mode 100644 apps/api/src/modules/booking-request/dto/accept-booking-request.dto.ts create mode 100644 apps/api/src/modules/booking-request/dto/amend-booking-request-stay.dto.ts create mode 100644 apps/api/src/modules/booking-request/dto/booking-request-payment.dto.ts create mode 100644 apps/api/src/modules/booking-request/dto/booking-request-response.dto.ts create mode 100644 apps/api/src/modules/booking-request/dto/create-request-card-setup.dto.ts create mode 100644 apps/api/src/modules/booking-request/dto/deny-booking-request.dto.ts create mode 100644 apps/api/src/modules/booking-request/dto/list-booking-request-audit.dto.ts create mode 100644 apps/api/src/modules/booking-request/dto/list-booking-requests.dto.ts create mode 100644 apps/api/src/modules/booking-request/dto/submit-booking-request.dto.ts create mode 100644 apps/api/src/modules/folio/folio-create-charge-http.spec.ts create mode 100644 apps/api/src/modules/folio/folio-stay-amendment.spec.ts create mode 100644 apps/api/src/modules/payment/booking-request-stripe-handler.interface.spec.ts create mode 100644 apps/api/src/modules/payment/booking-request-stripe-handler.interface.ts create mode 100644 apps/api/src/modules/payment/interfaces/saved-payment-method-gateway.interface.ts create mode 100644 apps/api/src/modules/payment/mock-saved-payment-method.gateway.spec.ts create mode 100644 apps/api/src/modules/payment/mock-saved-payment-method.gateway.ts create mode 100644 apps/api/src/modules/payment/payment-legacy-seam.spec.ts create mode 100644 apps/api/src/modules/payment/stripe-financial-state.spec.ts create mode 100644 apps/api/src/modules/payment/stripe-financial-state.ts create mode 100644 apps/api/src/modules/payment/stripe-saved-payment-method.gateway.spec.ts create mode 100644 apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts create mode 100644 apps/api/src/modules/payment/unsupported-saved-payment-method.gateway.ts create mode 100644 apps/api/src/modules/webhook/webhook.service.spec.ts create mode 100644 apps/booking/src/components/ConfiguredQuestion.tsx create mode 100644 apps/booking/src/components/MockSetupForm.tsx create mode 100644 apps/booking/src/components/RequestStayDocket.tsx create mode 100644 apps/booking/src/components/StripeSetupForm.tsx create mode 100644 apps/booking/src/lib/bookingRequestsFeature.ts create mode 100644 apps/booking/src/lib/format.test.ts create mode 100644 apps/booking/src/lib/requestCardConsent.ts create mode 100644 apps/booking/src/lib/requestPayload.ts create mode 100644 apps/booking/src/pages/RequestApplication.test.tsx create mode 100644 apps/booking/src/pages/RequestApplication.tsx create mode 100644 apps/booking/src/pages/RequestFlow.e2e.test.tsx create mode 100644 apps/booking/src/pages/RequestPayment.test.tsx create mode 100644 apps/booking/src/pages/RequestPayment.tsx create mode 100644 apps/booking/src/pages/RequestReceived.tsx create mode 100644 apps/dashboard/src/components/admin/BookingQuestionBuilder.test.tsx create mode 100644 apps/dashboard/src/components/admin/BookingQuestionBuilder.tsx create mode 100644 apps/dashboard/src/components/admin/booking-request-config.ts create mode 100644 apps/dashboard/src/components/booking-requests/AcceptRequestModal.tsx create mode 100644 apps/dashboard/src/components/booking-requests/DenyRequestModal.tsx create mode 100644 apps/dashboard/src/components/booking-requests/ModifyStayModal.test.tsx create mode 100644 apps/dashboard/src/components/booking-requests/ModifyStayModal.tsx create mode 100644 apps/dashboard/src/components/booking-requests/PaymentActionModal.tsx create mode 100644 apps/dashboard/src/components/booking-requests/RequestAudit.tsx create mode 100644 apps/dashboard/src/components/booking-requests/RequestMessages.tsx create mode 100644 apps/dashboard/src/components/booking-requests/RequestOverview.tsx create mode 100644 apps/dashboard/src/components/booking-requests/RequestPayments.tsx create mode 100644 apps/dashboard/src/components/booking-requests/bookingRequestPaymentsQuery.ts create mode 100644 apps/dashboard/src/components/booking-requests/moneyInput.test.ts create mode 100644 apps/dashboard/src/components/booking-requests/moneyInput.ts create mode 100644 apps/dashboard/src/components/booking-requests/queryKeys.ts create mode 100644 apps/dashboard/src/components/booking-requests/types.ts create mode 100644 apps/dashboard/src/hooks/useRealtimeInvalidation.test.ts create mode 100644 apps/dashboard/src/lib/bookingRequestsFeature.ts create mode 100644 apps/dashboard/src/pages/BookingRequests.test.tsx create mode 100644 apps/dashboard/src/pages/BookingRequests.tsx create mode 100644 packages/booking-requests/README.md create mode 100644 packages/booking-requests/package.json create mode 100644 packages/booking-requests/src/database/migrate.ts create mode 100644 packages/booking-requests/src/database/migrations/0022_booking_requests.sql create mode 100644 packages/booking-requests/src/database/migrations/0023_booking_request_accepted_pricing.sql create mode 100644 packages/booking-requests/src/database/migrations/0024_booking_request_payment_integrity.sql create mode 100644 packages/booking-requests/src/database/migrations/0025_booking_request_financial_recovery.sql create mode 100644 packages/booking-requests/src/database/migrations/0026_booking_request_email_recovery.sql create mode 100644 packages/booking-requests/src/database/migrations/0027_booking_request_email_retry_policy.sql create mode 100644 packages/booking-requests/src/database/migrations/0028_booking_request_audit_timeline_index.sql create mode 100644 packages/booking-requests/src/database/migrations/0029_booking_request_audit_relationship.sql create mode 100644 packages/booking-requests/src/database/migrations/0030_booking_request_stay_amendments.sql create mode 100644 packages/booking-requests/src/database/migrations/0031_booking_request_amendment_ledger.sql create mode 100644 packages/booking-requests/src/database/migrations/0032_booking_request_remediation.sql create mode 100644 packages/booking-requests/src/database/schema/booking-request.ts create mode 100644 packages/booking-requests/src/database/schema/index.ts create mode 100644 packages/booking-requests/src/enabled.ts create mode 100644 packages/booking-requests/src/index.ts create mode 100644 packages/booking-requests/src/register.module.ts create mode 100644 packages/booking-requests/tsconfig.json create mode 100644 packages/booking-requests/tsup.config.ts create mode 100644 packages/booking-requests/vitest.config.ts create mode 100644 packages/database/src/migrations/0022_webhook_logical_event_id.sql create mode 100644 packages/shared/src/sync-test-count-script.spec.js 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/release.yml b/.github/workflows/release.yml index de0f99c6..e9e04bcd 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 }} diff --git a/apps/api/package.json b/apps/api/package.json index a1ed495b..e1fcf582 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -31,6 +31,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", @@ -42,6 +43,7 @@ "fast-xml-parser": "^5.5.10", "jsonwebtoken": "^9.0.3", "jwks-rsa": "^4.0.1", + "nodemailer": "^9.0.5", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "postgres": "^3.4.5", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2b00fcf7..16d78cfd 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -53,6 +53,9 @@ 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 { createBookingRequestsRootModule } from '@telivityhaip/booking-requests'; +import { BookingRequestModule } from './modules/booking-request/booking-request.module'; +import { BOOKING_REQUEST_STRIPE_HANDLER } from './modules/payment/booking-request-stripe-handler.interface'; const imports: any[] = [ ConfigModule.forRoot({ @@ -70,6 +73,12 @@ const imports: any[] = [ FolioModule, RatePlanModule, PaymentModule, + ...(process.env['HAIP_BOOKING_REQUESTS'] === 'true' + ? [createBookingRequestsRootModule({ + bookingRequestModule: BookingRequestModule, + stripeHandlerToken: BOOKING_REQUEST_STRIPE_HANDLER, + })] + : []), HousekeepingModule, LostAndFoundModule, ServiceRequestsModule, 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/crypto/confirmation-number.spec.ts b/apps/api/src/common/crypto/confirmation-number.spec.ts new file mode 100644 index 00000000..038113f2 --- /dev/null +++ b/apps/api/src/common/crypto/confirmation-number.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from 'vitest'; +import { generateConfirmationNumber } from './confirmation-number'; + +describe('generateConfirmationNumber', () => { + it('uses a 128-bit cryptographic entropy seam and a non-enumerable shape', () => { + const entropy = vi.fn((bytes: number) => Buffer.alloc(bytes, 0xa5)); + + const confirmation = generateConfirmationNumber(entropy); + + expect(entropy).toHaveBeenCalledWith(16); + expect(confirmation).toMatch(/^HAIP-[0-9A-HJKMNP-TV-Z]{32}$/); + }); +}); diff --git a/apps/api/src/common/crypto/confirmation-number.ts b/apps/api/src/common/crypto/confirmation-number.ts new file mode 100644 index 00000000..171e3c9b --- /dev/null +++ b/apps/api/src/common/crypto/confirmation-number.ts @@ -0,0 +1,21 @@ +import { randomBytes } from 'node:crypto'; + +const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +export type ConfirmationEntropy = (bytes: number) => Uint8Array; + +/** A guest-facing bearer credential backed by exactly 128 bits of entropy. */ +export function generateConfirmationNumber( + entropy: ConfirmationEntropy = randomBytes, +): string { + const bytes = entropy(16); + if (bytes.length !== 16) { + throw new Error('Confirmation entropy source must return 16 bytes'); + } + let token = ''; + for (const byte of bytes) { + token += CROCKFORD[byte & 0x1f]; + token += CROCKFORD[(byte >> 5) & 0x1f]; + } + return `HAIP-${token}`; +} 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..08736e84 --- /dev/null +++ b/apps/api/src/common/database/accepted-pricing-lock.postgres.spec.ts @@ -0,0 +1,816 @@ +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 '../../modules/booking-request/booking-request.service'; +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/date/property-business-date.ts b/apps/api/src/common/date/property-business-date.ts new file mode 100644 index 00000000..20f05b8f --- /dev/null +++ b/apps/api/src/common/date/property-business-date.ts @@ -0,0 +1,18 @@ +/** Resolve a YYYY-MM-DD calendar date in an IANA timezone without server-UTC leakage. */ +export function calendarDateInTimeZone(now: Date, timeZone: string): string { + try { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const value = Object.fromEntries(parts.map((part) => [part.type, part.value])); + if (value['year'] && value['month'] && value['day']) { + return `${value['year']}-${value['month']}-${value['day']}`; + } + } catch { + // Invalid legacy timezone values fall back to UTC, matching other PMS dates. + } + return now.toISOString().slice(0, 10); +} diff --git a/apps/api/src/common/validation/is-money-string.validator.ts b/apps/api/src/common/validation/is-money-string.validator.ts index 71f6a9ea..1bfa5f3d 100644 --- a/apps/api/src/common/validation/is-money-string.validator.ts +++ b/apps/api/src/common/validation/is-money-string.validator.ts @@ -11,6 +11,8 @@ export interface MoneyStringOptions { 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 }) @@ -27,6 +29,7 @@ class MoneyStringConstraint implements ValidatorConstraintInterface { 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; } @@ -37,7 +40,9 @@ class MoneyStringConstraint implements ValidatorConstraintInterface { : opts.allowZero ? 'a non-negative numeric decimal string' : 'a positive numeric decimal string'; - return `${args?.property} must be ${bound}`; + return opts.maximum == null + ? `${args?.property} must be ${bound}` + : `${args?.property} must be ${bound} no greater than ${opts.maximum}`; } } diff --git a/apps/api/src/database/database.module.ts b/apps/api/src/database/database.module.ts index c6e4c64a..747f2aac 100644 --- a/apps/api/src/database/database.module.ts +++ b/apps/api/src/database/database.module.ts @@ -4,16 +4,23 @@ import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import * as schema from '@telivityhaip/database'; import { postgresOptionsFromEnv } from '@telivityhaip/database'; +import { isBookingRequestsEnabled } from '@telivityhaip/booking-requests'; export const DRIZZLE = Symbol('DRIZZLE'); +async function loadSchema() { + if (!isBookingRequestsEnabled()) return schema; + const bookingRequestsSchema = await import('@telivityhaip/booking-requests/schema'); + return { ...schema, ...bookingRequestsSchema }; +} + @Global() @Module({ providers: [ { provide: DRIZZLE, inject: [ConfigService], - useFactory: (config: ConfigService) => { + useFactory: async (config: ConfigService) => { const url = config.get( 'DATABASE_URL', 'postgresql://haip:haip@localhost:5432/haip', @@ -25,7 +32,8 @@ export const DRIZZLE = Symbol('DRIZZLE'); DATABASE_SSL: config.get('DATABASE_SSL'), }), ); - return drizzle(client, { schema }); + const mergedSchema = await loadSchema(); + return drizzle(client, { schema: mergedSchema }); }, }, ], diff --git a/apps/api/src/modules/agent/guest-comms/email-provider.interface.ts b/apps/api/src/modules/agent/guest-comms/email-provider.interface.ts index dbca9dda..b70f4367 100644 --- a/apps/api/src/modules/agent/guest-comms/email-provider.interface.ts +++ b/apps/api/src/modules/agent/guest-comms/email-provider.interface.ts @@ -4,6 +4,10 @@ export interface EmailMessage { html: string; text: string; from?: string; + /** Stable caller identity for providers/gateways that support deduplication. */ + idempotencyKey?: string; + /** Stable RFC Message-ID reused when an at-least-once transport is retried. */ + messageId?: string; } export interface EmailResult { @@ -11,12 +15,19 @@ export interface EmailResult { messageId?: string; provider?: string; error?: string; + /** True when the transport may have accepted mail before timing out. */ + outcomeUnknown?: boolean; +} + +export interface EmailSendOptions { + /** Hard upper bound requested by the caller for transport settlement. */ + timeoutMs?: number; } export interface EmailProvider { readonly name: string; isConfigured(): boolean; - send(message: EmailMessage): Promise; + send(message: EmailMessage, options?: EmailSendOptions): Promise; } export const EMAIL_PROVIDERS = Symbol('EMAIL_PROVIDERS'); diff --git a/apps/api/src/modules/agent/guest-comms/email.service.spec.ts b/apps/api/src/modules/agent/guest-comms/email.service.spec.ts index f7d5fc80..80d6ba57 100644 --- a/apps/api/src/modules/agent/guest-comms/email.service.spec.ts +++ b/apps/api/src/modules/agent/guest-comms/email.service.spec.ts @@ -36,6 +36,28 @@ describe('EmailService', () => { expect(smtp.send).not.toHaveBeenCalled(); }); + it('passes stable transport identity through to the selected provider', async () => { + const provider = { + name: 'sendgrid', + isConfigured: () => true, + send: vi.fn().mockResolvedValue({ sent: true, messageId: 'provider-id' }), + }; + const service = new EmailService([provider]); + const message = { + to: 'guest@example.com', + subject: 'Hi', + html: '

Hi

', + text: 'Hi', + idempotencyKey: 'booking-request-email:delivery-1', + messageId: '', + }; + await service.send(message, { timeoutMs: 1_234 }); + expect(provider.send).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: 'booking-request-email:delivery-1', + messageId: '', + }), { timeoutMs: 1_234 }); + }); + it('falls back to console when no real provider is configured', async () => { const smtp = { name: 'smtp', isConfigured: () => false, send: vi.fn() }; const sendgrid = { name: 'sendgrid', isConfigured: () => false, send: vi.fn() }; @@ -56,6 +78,7 @@ describe('SendgridEmailProvider', () => { const originalEnv = { ...process.env }; afterEach(() => { + vi.useRealTimers(); global.fetch = originalFetch; process.env = { ...originalEnv }; vi.resetModules(); @@ -92,11 +115,56 @@ describe('SendgridEmailProvider', () => { subject: 'Confirm', html: '

Hi

', text: 'Hi', + idempotencyKey: 'stable-delivery-1', + messageId: '', }); expect(result.sent).toBe(true); expect(global.fetch).toHaveBeenCalledWith( 'https://api.sendgrid.com/v3/mail/send', expect.objectContaining({ method: 'POST' }), ); + const init = vi.mocked(global.fetch).mock.calls[0]?.[1]; + const payload = JSON.parse(String(init?.body)); + expect(payload.personalizations[0]).toMatchObject({ + headers: { 'Message-ID': '' }, + custom_args: { haip_idempotency_key: 'stable-delivery-1' }, + }); + }); + + it('aborts and awaits settlement of a bounded SendGrid request', async () => { + vi.useFakeTimers(); + process.env['SENDGRID_API_KEY'] = 'SG.test'; + process.env['SENDGRID_FROM'] = 'hotel@example.com'; + let fetchSettled = false; + global.fetch = vi.fn((_url, init) => new Promise((_resolve, reject) => { + const signal = init?.signal; + signal?.addEventListener('abort', () => { + queueMicrotask(() => { + fetchSettled = true; + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + }, { once: true }); + })) as any; + + const { SendgridEmailProvider } = await import('./providers/sendgrid-email.provider'); + const provider = new SendgridEmailProvider(); + const sending = provider.send({ + to: 'guest@example.com', + subject: 'Confirm', + html: '

Hi

', + text: 'Hi', + }, { timeoutMs: 100 }); + await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledOnce()); + const signal = vi.mocked(global.fetch).mock.calls[0]?.[1]?.signal; + expect(signal).toBeInstanceOf(AbortSignal); + + await vi.advanceTimersByTimeAsync(100); + await expect(sending).resolves.toMatchObject({ + sent: false, + outcomeUnknown: true, + error: 'Email transport timed out', + }); + expect(signal?.aborted).toBe(true); + expect(fetchSettled).toBe(true); }); }); diff --git a/apps/api/src/modules/agent/guest-comms/email.service.ts b/apps/api/src/modules/agent/guest-comms/email.service.ts index e6e6689c..fd15f059 100644 --- a/apps/api/src/modules/agent/guest-comms/email.service.ts +++ b/apps/api/src/modules/agent/guest-comms/email.service.ts @@ -1,8 +1,13 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; -import type { EmailMessage, EmailProvider, EmailResult } from './email-provider.interface'; +import type { + EmailMessage, + EmailProvider, + EmailResult, + EmailSendOptions, +} from './email-provider.interface'; import { EMAIL_PROVIDERS } from './email-provider.interface'; -export type { EmailMessage, EmailResult } from './email-provider.interface'; +export type { EmailMessage, EmailResult, EmailSendOptions } from './email-provider.interface'; /** * Email transport service — SendGrid, Mailgun, SES gateway, SMTP, or console fallback. @@ -19,9 +24,9 @@ export class EmailService { return this.providers.some((p) => p.name !== 'console' && p.isConfigured()); } - async send(message: EmailMessage): Promise { + async send(message: EmailMessage, options?: EmailSendOptions): Promise { const provider = this.activeProvider(); - const result = await provider.send(message); + const result = await provider.send(message, options); if (!result.provider) { return { ...result, provider: provider.name }; } diff --git a/apps/api/src/modules/agent/guest-comms/providers/bounded-email-transport.ts b/apps/api/src/modules/agent/guest-comms/providers/bounded-email-transport.ts new file mode 100644 index 00000000..c42e3aac --- /dev/null +++ b/apps/api/src/modules/agent/guest-comms/providers/bounded-email-transport.ts @@ -0,0 +1,63 @@ +import type { EmailSendOptions } from '../email-provider.interface'; + +export const DEFAULT_EMAIL_SEND_TIMEOUT_MS = 60_000; + +export class EmailTransportTimeoutError extends Error { + constructor() { + super('Email transport timed out'); + this.name = 'EmailTransportTimeoutError'; + } +} + +export function emailSendTimeoutMs(options?: EmailSendOptions): number { + const requested = options?.timeoutMs; + if (!Number.isFinite(requested) || requested === undefined) { + return DEFAULT_EMAIL_SEND_TIMEOUT_MS; + } + return Math.max(1, Math.floor(requested)); +} + +/** + * Aborts an HTTP transport at the deadline but does not return until fetch has + * actually settled, so callers never make the delivery retry-eligible while + * the original in-process request is still live. + */ +export async function boundedEmailFetch( + input: string, + init: RequestInit, + options: EmailSendOptions | undefined, + consume: (response: Response) => Promise | T, +): Promise { + const controller = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + controller.abort(); + }, emailSendTimeoutMs(options)); + timeout.unref?.(); + try { + const response = await fetch(input, { ...init, signal: controller.signal }); + const result = await consume(response); + if (timedOut) throw new EmailTransportTimeoutError(); + return result; + } catch (error: unknown) { + if (timedOut) throw new EmailTransportTimeoutError(); + throw error; + } finally { + clearTimeout(timeout); + } +} + +export function unknownTimeoutResult(provider: string): { + sent: false; + provider: string; + error: string; + outcomeUnknown: true; +} { + return { + sent: false, + provider, + error: 'Email transport timed out', + outcomeUnknown: true, + }; +} diff --git a/apps/api/src/modules/agent/guest-comms/providers/console-email.provider.ts b/apps/api/src/modules/agent/guest-comms/providers/console-email.provider.ts index 9821af49..dd4d349a 100644 --- a/apps/api/src/modules/agent/guest-comms/providers/console-email.provider.ts +++ b/apps/api/src/modules/agent/guest-comms/providers/console-email.provider.ts @@ -20,7 +20,7 @@ export class ConsoleEmailProvider implements EmailProvider { return { sent: false, provider: this.name, - messageId: `console-${Date.now()}`, + messageId: message.messageId ?? `console-${Date.now()}`, error: 'No email provider configured — message logged only', }; } diff --git a/apps/api/src/modules/agent/guest-comms/providers/mailgun-email.provider.ts b/apps/api/src/modules/agent/guest-comms/providers/mailgun-email.provider.ts index 2a395095..c3d6c64c 100644 --- a/apps/api/src/modules/agent/guest-comms/providers/mailgun-email.provider.ts +++ b/apps/api/src/modules/agent/guest-comms/providers/mailgun-email.provider.ts @@ -1,5 +1,15 @@ import { Injectable, Logger } from '@nestjs/common'; -import type { EmailMessage, EmailProvider, EmailResult } from '../email-provider.interface'; +import type { + EmailMessage, + EmailProvider, + EmailResult, + EmailSendOptions, +} from '../email-provider.interface'; +import { + boundedEmailFetch, + EmailTransportTimeoutError, + unknownTimeoutResult, +} from './bounded-email-transport'; /** * Mailgun Messages API adapter. @@ -24,7 +34,7 @@ export class MailgunEmailProvider implements EmailProvider { return Boolean(this.apiKey && this.domain && this.defaultFrom); } - async send(message: EmailMessage): Promise { + async send(message: EmailMessage, options?: EmailSendOptions): Promise { if (!this.isConfigured()) { return { sent: false, provider: this.name, error: 'Mailgun not configured' }; } @@ -35,21 +45,32 @@ export class MailgunEmailProvider implements EmailProvider { form.set('subject', message.subject); form.set('text', message.text); form.set('html', message.html); + if (message.messageId) form.set('h:Message-Id', message.messageId); + if (message.idempotencyKey) { + form.set('v:haip-idempotency-key', message.idempotencyKey); + } try { const auth = Buffer.from(`api:${this.apiKey}`).toString('base64'); - const res = await fetch(`${this.apiBase}/v3/${this.domain}/messages`, { - method: 'POST', - headers: { - Authorization: `Basic ${auth}`, - 'Content-Type': 'application/x-www-form-urlencoded', + const { response: res, payload } = await boundedEmailFetch( + `${this.apiBase}/v3/${this.domain}/messages`, + { + method: 'POST', + headers: { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: form.toString(), }, - body: form.toString(), - }); - const payload = (await res.json().catch(() => ({}))) as { - id?: string; - message?: string; - }; + options, + async (response) => ({ + response, + payload: (await response.json().catch(() => ({}))) as { + id?: string; + message?: string; + }, + }), + ); if (!res.ok) { return { sent: false, @@ -60,6 +81,9 @@ export class MailgunEmailProvider implements EmailProvider { this.logger.log(`Email sent via Mailgun to ${message.to}`); return { sent: true, provider: this.name, messageId: payload.id }; } catch (error: any) { + if (error instanceof EmailTransportTimeoutError) { + return unknownTimeoutResult(this.name); + } this.logger.error(`Mailgun send failed: ${error.message}`); return { sent: false, provider: this.name, error: error.message }; } diff --git a/apps/api/src/modules/agent/guest-comms/providers/mailgun-ses.provider.spec.ts b/apps/api/src/modules/agent/guest-comms/providers/mailgun-ses.provider.spec.ts index c430236f..29ff0d8d 100644 --- a/apps/api/src/modules/agent/guest-comms/providers/mailgun-ses.provider.spec.ts +++ b/apps/api/src/modules/agent/guest-comms/providers/mailgun-ses.provider.spec.ts @@ -5,6 +5,7 @@ describe('MailgunEmailProvider', () => { const originalEnv = { ...process.env }; afterEach(() => { + vi.useRealTimers(); global.fetch = originalFetch; process.env = { ...originalEnv }; vi.resetModules(); @@ -33,9 +34,79 @@ describe('MailgunEmailProvider', () => { subject: 'S', html: 'h', text: 't', + idempotencyKey: 'stable-delivery-1', + messageId: '', }); expect(result.sent).toBe(true); expect(result.messageId).toBe(''); + const init = vi.mocked(global.fetch).mock.calls[0]?.[1]; + const form = new URLSearchParams(String(init?.body)); + expect(form.get('h:Message-Id')).toBe(''); + expect(form.get('v:haip-idempotency-key')).toBe('stable-delivery-1'); + }); + + it('aborts and awaits settlement of a bounded Mailgun request', async () => { + vi.useFakeTimers(); + process.env['MAILGUN_API_KEY'] = 'key'; + process.env['MAILGUN_DOMAIN'] = 'mg.example.com'; + let settled = false; + global.fetch = vi.fn((_url, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + queueMicrotask(() => { + settled = true; + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + }, { once: true }); + })) as any; + + const { MailgunEmailProvider } = await import('./mailgun-email.provider'); + const provider = new MailgunEmailProvider(); + const sending = provider.send({ + to: 'a@b.com', subject: 'S', html: 'h', text: 't', + }, { timeoutMs: 100 }); + await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledOnce()); + const signal = vi.mocked(global.fetch).mock.calls[0]?.[1]?.signal; + expect(signal).toBeInstanceOf(AbortSignal); + await vi.advanceTimersByTimeAsync(100); + + await expect(sending).resolves.toMatchObject({ + sent: false, outcomeUnknown: true, error: 'Email transport timed out', + }); + expect(signal?.aborted).toBe(true); + expect(settled).toBe(true); + }); + + it('keeps the bound active until the Mailgun response body settles', async () => { + vi.useFakeTimers(); + process.env['MAILGUN_API_KEY'] = 'key'; + process.env['MAILGUN_DOMAIN'] = 'mg.example.com'; + let bodySettled = false; + global.fetch = vi.fn((_url, init) => Promise.resolve({ + ok: true, + json: () => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + queueMicrotask(() => { + bodySettled = true; + reject(Object.assign(new Error('aborted body'), { name: 'AbortError' })); + }); + }, { once: true }); + }), + })) as any; + + const { MailgunEmailProvider } = await import('./mailgun-email.provider'); + const provider = new MailgunEmailProvider(); + const sending = provider.send({ + to: 'a@b.com', subject: 'S', html: 'h', text: 't', + }, { timeoutMs: 100 }); + await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledOnce()); + const signal = vi.mocked(global.fetch).mock.calls[0]?.[1]?.signal; + await vi.advanceTimersByTimeAsync(100); + + expect(signal?.aborted).toBe(true); + await expect(sending).resolves.toMatchObject({ + sent: false, outcomeUnknown: true, error: 'Email transport timed out', + }); + expect(bodySettled).toBe(true); }); }); @@ -44,6 +115,7 @@ describe('SesEmailProvider', () => { const originalEnv = { ...process.env }; afterEach(() => { + vi.useRealTimers(); global.fetch = originalFetch; process.env = { ...originalEnv }; vi.resetModules(); @@ -73,11 +145,56 @@ describe('SesEmailProvider', () => { subject: 'S', html: 'h', text: 't', + idempotencyKey: 'stable-delivery-1', + messageId: '', }); expect(result).toEqual({ sent: true, provider: 'amazon-ses', messageId: 'ses-1', }); + const init = vi.mocked(global.fetch).mock.calls[0]?.[1]; + expect(init?.headers).toMatchObject({ + 'X-HAIP-Idempotency-Key': 'stable-delivery-1', + }); + const payload = JSON.parse(String(init?.body)); + expect(payload.Content.Simple.Headers).toContainEqual({ + Name: 'X-HAIP-Message-ID', Value: '', + }); + expect(payload.Content.Simple.Headers).not.toContainEqual(expect.objectContaining({ + Name: 'Message-ID', + })); + }); + + it('aborts and awaits settlement of a bounded SES gateway request', async () => { + vi.useFakeTimers(); + process.env['SES_ENDPOINT'] = 'http://localhost:4566'; + process.env['SES_API_KEY'] = 'local'; + process.env['SES_FROM'] = 'noreply@example.com'; + let settled = false; + global.fetch = vi.fn((_url, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + queueMicrotask(() => { + settled = true; + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + }, { once: true }); + })) as any; + + const { SesEmailProvider } = await import('./ses-email.provider'); + const provider = new SesEmailProvider(); + const sending = provider.send({ + to: 'a@b.com', subject: 'S', html: 'h', text: 't', + }, { timeoutMs: 100 }); + await vi.waitFor(() => expect(global.fetch).toHaveBeenCalledOnce()); + const signal = vi.mocked(global.fetch).mock.calls[0]?.[1]?.signal; + expect(signal).toBeInstanceOf(AbortSignal); + await vi.advanceTimersByTimeAsync(100); + + await expect(sending).resolves.toMatchObject({ + sent: false, outcomeUnknown: true, error: 'Email transport timed out', + }); + expect(signal?.aborted).toBe(true); + expect(settled).toBe(true); }); }); diff --git a/apps/api/src/modules/agent/guest-comms/providers/sendgrid-email.provider.ts b/apps/api/src/modules/agent/guest-comms/providers/sendgrid-email.provider.ts index 9bf80336..133f0970 100644 --- a/apps/api/src/modules/agent/guest-comms/providers/sendgrid-email.provider.ts +++ b/apps/api/src/modules/agent/guest-comms/providers/sendgrid-email.provider.ts @@ -1,5 +1,15 @@ import { Injectable, Logger } from '@nestjs/common'; -import type { EmailMessage, EmailProvider, EmailResult } from '../email-provider.interface'; +import type { + EmailMessage, + EmailProvider, + EmailResult, + EmailSendOptions, +} from '../email-provider.interface'; +import { + boundedEmailFetch, + EmailTransportTimeoutError, + unknownTimeoutResult, +} from './bounded-email-transport'; /** * SendGrid Email API reference adapter. @@ -32,14 +42,23 @@ export class SendgridEmailProvider implements EmailProvider { return Boolean(this.apiKey && this.defaultFrom); } - async send(message: EmailMessage): Promise { + async send(message: EmailMessage, options?: EmailSendOptions): Promise { if (!this.isConfigured()) { return { sent: false, provider: this.name, error: 'SendGrid not configured' }; } const from = message.from ?? this.defaultFrom!; + const personalization: { + to: Array<{ email: string }>; + headers?: Record; + custom_args?: Record; + } = { to: [{ email: message.to }] }; + if (message.messageId) personalization.headers = { 'Message-ID': message.messageId }; + if (message.idempotencyKey) { + personalization.custom_args = { haip_idempotency_key: message.idempotencyKey }; + } const payload = { - personalizations: [{ to: [{ email: message.to }] }], + personalizations: [personalization], from: { email: from }, subject: message.subject, content: [ @@ -49,18 +68,25 @@ export class SendgridEmailProvider implements EmailProvider { }; try { - const res = await fetch('https://api.sendgrid.com/v3/mail/send', { - method: 'POST', - headers: { - Authorization: `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json', + const { response: res, failureBody } = await boundedEmailFetch( + 'https://api.sendgrid.com/v3/mail/send', + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), }, - body: JSON.stringify(payload), - }); + options, + async (response) => ({ + response, + failureBody: response.ok ? undefined : await response.text(), + }), + ); if (!res.ok) { - const body = await res.text(); - this.logger.error(`SendGrid send failed (${res.status}): ${body}`); + this.logger.error(`SendGrid send failed (${res.status}): ${failureBody ?? ''}`); return { sent: false, provider: this.name, error: `SendGrid HTTP ${res.status}` }; } @@ -68,6 +94,9 @@ export class SendgridEmailProvider implements EmailProvider { this.logger.log(`Email sent via SendGrid to ${message.to}`); return { sent: true, provider: this.name, messageId }; } catch (error: any) { + if (error instanceof EmailTransportTimeoutError) { + return unknownTimeoutResult(this.name); + } this.logger.error(`SendGrid send failed: ${error.message}`); return { sent: false, provider: this.name, error: error.message }; } diff --git a/apps/api/src/modules/agent/guest-comms/providers/ses-email.provider.ts b/apps/api/src/modules/agent/guest-comms/providers/ses-email.provider.ts index 4763e5f4..970cec0c 100644 --- a/apps/api/src/modules/agent/guest-comms/providers/ses-email.provider.ts +++ b/apps/api/src/modules/agent/guest-comms/providers/ses-email.provider.ts @@ -1,5 +1,15 @@ import { Injectable, Logger } from '@nestjs/common'; -import type { EmailMessage, EmailProvider, EmailResult } from '../email-provider.interface'; +import type { + EmailMessage, + EmailProvider, + EmailResult, + EmailSendOptions, +} from '../email-provider.interface'; +import { + boundedEmailFetch, + EmailTransportTimeoutError, + unknownTimeoutResult, +} from './bounded-email-transport'; /** * Amazon SES outbound adapter via an explicit HTTPS gateway. @@ -22,7 +32,7 @@ export class SesEmailProvider implements EmailProvider { return Boolean(this.from && this.endpoint && this.apiKey); } - async send(message: EmailMessage): Promise { + async send(message: EmailMessage, options?: EmailSendOptions): Promise { if (!this.isConfigured()) { return { sent: false, @@ -37,6 +47,11 @@ export class SesEmailProvider implements EmailProvider { Content: { Simple: { Subject: { Data: message.subject }, + // SES assigns and overwrites the RFC Message-ID. Preserve our stable + // logical identity in a permitted custom header for gateway replay. + ...(message.messageId + ? { Headers: [{ Name: 'X-HAIP-Message-ID', Value: message.messageId }] } + : {}), Body: { Text: { Data: message.text }, Html: { Data: message.html }, @@ -46,19 +61,29 @@ export class SesEmailProvider implements EmailProvider { }; try { - const res = await fetch(`${this.endpoint!.replace(/\/$/, '')}/v2/email/outbound-emails`, { - method: 'POST', - headers: { - Authorization: `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json', - 'X-SES-Region': this.region, + const { response: res, body } = await boundedEmailFetch( + `${this.endpoint!.replace(/\/$/, '')}/v2/email/outbound-emails`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + 'X-SES-Region': this.region, + ...(message.idempotencyKey + ? { 'X-HAIP-Idempotency-Key': message.idempotencyKey } + : {}), + }, + body: JSON.stringify(payload), }, - body: JSON.stringify(payload), - }); - const body = (await res.json().catch(() => ({}))) as { - MessageId?: string; - message?: string; - }; + options, + async (response) => ({ + response, + body: (await response.json().catch(() => ({}))) as { + MessageId?: string; + message?: string; + }, + }), + ); if (!res.ok) { return { sent: false, @@ -69,6 +94,9 @@ export class SesEmailProvider implements EmailProvider { this.logger.log(`Email sent via SES gateway to ${message.to}`); return { sent: true, provider: this.name, messageId: body.MessageId }; } catch (error: any) { + if (error instanceof EmailTransportTimeoutError) { + return unknownTimeoutResult(this.name); + } this.logger.error(`SES send failed: ${error.message}`); return { sent: false, provider: this.name, error: error.message }; } diff --git a/apps/api/src/modules/agent/guest-comms/providers/smtp-email.provider.spec.ts b/apps/api/src/modules/agent/guest-comms/providers/smtp-email.provider.spec.ts new file mode 100644 index 00000000..5a042fc9 --- /dev/null +++ b/apps/api/src/modules/agent/guest-comms/providers/smtp-email.provider.spec.ts @@ -0,0 +1,162 @@ +import { createServer, type Socket } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SmtpEmailProvider } from './smtp-email.provider'; + +describe('SmtpEmailProvider bounded send', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('settles and closes a connection that never sends its SMTP greeting', async () => { + const sockets = new Set(); + const server = createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('SMTP test server did not bind'); + process.env['SMTP_HOST'] = '127.0.0.1'; + process.env['SMTP_PORT'] = String(address.port); + delete process.env['SMTP_USER']; + delete process.env['SMTP_PASS']; + const provider = new SmtpEmailProvider(); + const didNotSettle = Symbol('did-not-settle'); + + try { + const result = await Promise.race([ + provider.send({ + to: 'guest@example.com', + subject: 'Hi', + html: '

Hi

', + text: 'Hi', + messageId: '', + }, { timeoutMs: 50 }), + new Promise((resolve) => { + setTimeout(() => resolve(didNotSettle), 500); + }), + ]); + + expect(result).not.toBe(didNotSettle); + expect(result).toMatchObject({ + sent: false, + provider: 'smtp', + outcomeUnknown: true, + error: 'Email transport timed out', + }); + await vi.waitFor(() => expect(sockets.size).toBe(0), { timeout: 500 }); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + } + }); + + it('hard-closes an active SMTP transaction before a later send can connect', async () => { + const sockets = new Set(); + const heartbeats = new Map>(); + let sessionsAtData = 0; + let maxConcurrentConnections = 0; + const server = createServer({ allowHalfOpen: true }, (socket) => { + sockets.add(socket); + maxConcurrentConnections = Math.max(maxConcurrentConnections, sockets.size); + let input = ''; + let readingData = false; + + socket.write('220 smtp.test ESMTP ready\r\n'); + socket.on('data', (chunk) => { + input += chunk.toString('utf8'); + if (readingData) { + const dataEnd = input.indexOf('\r\n.\r\n'); + if (dataEnd < 0) return; + input = input.slice(dataEnd + 5); + readingData = false; + sessionsAtData += 1; + // Keep TCP traffic flowing without completing the SMTP DATA reply. + // This defeats Nodemailer's socket-inactivity timeout so only the + // provider's owned hard deadline can end the live transaction. + const heartbeat = setInterval(() => { + if (!socket.destroyed && socket.writable) socket.write(' '); + }, 5); + heartbeats.set(socket, heartbeat); + return; + } + + let lineEnd: number; + while ((lineEnd = input.indexOf('\r\n')) >= 0) { + const command = input.slice(0, lineEnd); + input = input.slice(lineEnd + 2); + if (/^EHLO /i.test(command)) { + socket.write('250-smtp.test\r\n250 PIPELINING\r\n'); + } else if (/^MAIL FROM:/i.test(command) || /^RCPT TO:/i.test(command)) { + socket.write('250 2.1.0 Ok\r\n'); + } else if (/^DATA$/i.test(command)) { + readingData = true; + socket.write('354 End data with .\r\n'); + if (input.includes('\r\n.\r\n')) { + socket.emit('data', Buffer.alloc(0)); + } + break; + } else if (/^QUIT$/i.test(command)) { + socket.write('221 2.0.0 Bye\r\n'); + socket.end(); + } + } + }); + socket.on('error', () => undefined); + socket.on('close', () => { + sockets.delete(socket); + const heartbeat = heartbeats.get(socket); + if (heartbeat) clearInterval(heartbeat); + heartbeats.delete(socket); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('SMTP test server did not bind'); + process.env['SMTP_HOST'] = '127.0.0.1'; + process.env['SMTP_PORT'] = String(address.port); + delete process.env['SMTP_USER']; + delete process.env['SMTP_PASS']; + const provider = new SmtpEmailProvider(); + const didNotSettle = Symbol('did-not-settle'); + const send = (suffix: string) => provider.send({ + to: 'guest@example.com', + subject: `Hi ${suffix}`, + html: '

Hi

', + text: 'Hi', + messageId: ``, + }, { timeoutMs: 50 }); + + try { + for (const suffix of ['one', 'two']) { + const result = await Promise.race([ + send(suffix), + new Promise((resolve) => { + setTimeout(() => resolve(didNotSettle), 500); + }), + ]); + + expect(result).not.toBe(didNotSettle); + expect(result).toMatchObject({ + sent: false, + provider: 'smtp', + outcomeUnknown: true, + error: 'Email transport timed out', + }); + await vi.waitFor(() => expect(sockets.size).toBe(0), { timeout: 500 }); + } + expect(sessionsAtData).toBe(2); + expect(maxConcurrentConnections).toBe(1); + } finally { + for (const heartbeat of heartbeats.values()) clearInterval(heartbeat); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + } + }); +}); diff --git a/apps/api/src/modules/agent/guest-comms/providers/smtp-email.provider.ts b/apps/api/src/modules/agent/guest-comms/providers/smtp-email.provider.ts index 4dab8d42..6b49ddf4 100644 --- a/apps/api/src/modules/agent/guest-comms/providers/smtp-email.provider.ts +++ b/apps/api/src/modules/agent/guest-comms/providers/smtp-email.provider.ts @@ -1,5 +1,38 @@ import { Injectable, Logger } from '@nestjs/common'; -import type { EmailMessage, EmailProvider, EmailResult } from '../email-provider.interface'; +import type { + EmailMessage, + EmailProvider, + EmailResult, + EmailSendOptions, +} from '../email-provider.interface'; +import { + emailSendTimeoutMs, + unknownTimeoutResult, +} from './bounded-email-transport'; + +interface OwnedSmtpPoolResource { + connection?: { + _socket?: OwnedSmtpConnectionSocket; + }; + close?: () => void; +} + +interface OwnedSmtpSocket { + destroyed?: boolean; + destroy?: () => void; +} + +interface OwnedSmtpConnectionSocket extends OwnedSmtpSocket { + socket?: OwnedSmtpSocket; +} + +interface OwnedSmtpTransport { + close?: () => void; + transporter?: { + _connections?: OwnedSmtpPoolResource[]; + }; + sendMail: (message: Record) => Promise<{ messageId: string }>; +} /** * SMTP transport (nodemailer) — configured via SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM. @@ -8,7 +41,8 @@ import type { EmailMessage, EmailProvider, EmailResult } from '../email-provider export class SmtpEmailProvider implements EmailProvider { readonly name = 'smtp'; private readonly logger = new Logger(SmtpEmailProvider.name); - private transport: any = null; + private nodemailer: any = null; + private transportConfig: Record | null = null; constructor() { this.initTransport(); @@ -28,12 +62,13 @@ export class SmtpEmailProvider implements EmailProvider { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const nodemailer = require('nodemailer'); - this.transport = nodemailer.createTransport({ + this.nodemailer = nodemailer; + this.transportConfig = { host, port: parseInt(port, 10), secure: parseInt(port, 10) === 465, auth: user && pass ? { user, pass } : undefined, - }); + }; this.logger.log(`SMTP email provider configured: ${host}:${port}`); } catch { this.logger.warn('nodemailer not available — SMTP email provider disabled'); @@ -41,29 +76,87 @@ export class SmtpEmailProvider implements EmailProvider { } isConfigured(): boolean { - return this.transport !== null; + return this.nodemailer !== null && this.transportConfig !== null; } - async send(message: EmailMessage): Promise { - if (!this.transport) { + async send(message: EmailMessage, options?: EmailSendOptions): Promise { + if (!this.isConfigured()) { return { sent: false, provider: this.name, error: 'SMTP not configured' }; } + const timeoutMs = emailSendTimeoutMs(options); + const transport = this.nodemailer.createTransport({ + ...this.transportConfig, + pool: true, + maxConnections: 1, + maxMessages: 1, + maxRequeues: 0, + connectionTimeout: timeoutMs, + greetingTimeout: timeoutMs, + socketTimeout: timeoutMs, + dnsTimeout: timeoutMs, + }) as OwnedSmtpTransport; + let timedOut = false; + let lateClose: ReturnType | undefined; + const timeout = setTimeout(() => { + timedOut = true; + this.closeOwnedTransport(transport); + // Pool resource setup itself is asynchronous. Re-close on the next turn + // so a resource created at the deadline cannot outlive this send. + lateClose = setImmediate(() => this.closeOwnedTransport(transport)); + lateClose.unref?.(); + }, timeoutMs); + timeout.unref?.(); try { const from = message.from ?? process.env['SMTP_FROM'] ?? 'noreply@haip.dev'; - const info = await this.transport.sendMail({ + const info = await transport.sendMail({ from, to: message.to, subject: message.subject, html: message.html, text: message.text, + messageId: message.messageId, + headers: message.idempotencyKey + ? { 'X-HAIP-Idempotency-Key': message.idempotencyKey } + : undefined, }); + if (timedOut) return unknownTimeoutResult(this.name); this.logger.log(`Email sent via SMTP to ${message.to}: ${info.messageId}`); return { sent: true, provider: this.name, messageId: info.messageId }; } catch (error: any) { + if ( + timedOut + || error?.code === 'ETIMEDOUT' + || /timed?\s*out|greeting never received/i.test(String(error?.message)) + ) { + return unknownTimeoutResult(this.name); + } this.logger.error(`SMTP send failed to ${message.to}: ${error.message}`); return { sent: false, provider: this.name, error: error.message }; + } finally { + clearTimeout(timeout); + if (lateClose) clearImmediate(lateClose); + // This runs only after sendMail has settled. Destroying again here makes + // return from send() the ownership boundary for every per-send socket. + this.closeOwnedTransport(transport); + } + } + + private closeOwnedTransport(transport: OwnedSmtpTransport): void { + const resources = [...(transport.transporter?._connections ?? [])]; + const sockets = resources.map((resource) => { + const wrappedSocket = resource.connection?._socket; + return wrappedSocket?.socket ?? wrappedSocket; + }); + + // Marks the pool closed and fails any work that has not acquired a resource. + transport.close?.(); + for (const resource of resources) resource.close?.(); + // SMTPConnection.close() is graceful after greeting. A hard deadline also + // destroys the owned socket so an active half-open transaction cannot live. + for (const socket of sockets) { + if (!socket?.destroyed) socket?.destroy?.(); } } } 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/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..9f9e5528 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,26 @@ -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 { 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 +46,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 +157,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 +181,121 @@ 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(); + + 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..33f79064 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,6 @@ import { PolicyModule } from '../policy/policy.module'; BookingEngineScopeGuard, BookingThrottleGuard, ], - exports: [BookingEngineConfigService], + exports: [BookingEngineService, BookingEngineConfigService], }) 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..877b94cb --- /dev/null +++ b/apps/api/src/modules/booking-engine/booking-form-questions.spec.ts @@ -0,0 +1,687 @@ +import { BadRequestException, ConflictException, ValidationPipe } from '@nestjs/common'; +import { 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', + }; + + 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'); + }); +}); 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/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-allocation-reconciler.spec.ts b/apps/api/src/modules/booking-request/booking-request-allocation-reconciler.spec.ts new file mode 100644 index 00000000..2d1208cf --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-allocation-reconciler.spec.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + auditLogs, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + payments, +} from './booking-request-db.js'; +import { + planNetAllocationReconciliation, + reconcileBookingRequestPaymentAllocations, +} from './booking-request-allocation-reconciler'; + +describe('Booking Request net allocation reconciliation', () => { + const allocations = [ + { + id: 'allocation-1', + installmentId: 'installment-1', + amount: '60.00', + createdAt: new Date('2026-08-20T10:00:00.000Z'), + }, + { + id: 'allocation-2', + installmentId: 'installment-2', + amount: '40.00', + createdAt: new Date('2026-08-21T10:00:00.000Z'), + }, + ]; + + it('deterministically releases the newest allocations when net capture falls', () => { + expect(planNetAllocationReconciliation('70.00', allocations)).toEqual({ + allocationAmounts: new Map([ + ['allocation-1', '60.00'], + ['allocation-2', '10.00'], + ]), + installmentTotals: new Map([ + ['installment-1', '60.00'], + ['installment-2', '10.00'], + ]), + }); + }); + + it('releases every allocation when a movement is fully returned', () => { + expect(planNetAllocationReconciliation('0.00', allocations)).toEqual({ + allocationAmounts: new Map([ + ['allocation-1', '0.00'], + ['allocation-2', '0.00'], + ]), + installmentTotals: new Map([ + ['installment-1', '0.00'], + ['installment-2', '0.00'], + ]), + }); + }); + + it('atomically reduces persisted allocations and recomputes each installment', async () => { + const allocationRows = allocations.map((row) => ({ + ...row, + propertyId: 'property-1', + bookingRequestId: 'request-1', + paymentId: 'payment-1', + })); + const childRows = [{ + id: 'return-1', + propertyId: 'property-1', + bookingRequestId: 'request-1', + originalPaymentId: 'payment-1', + status: 'captured', + amount: '-30.00', + }]; + const installmentRows = new Map([ + ['installment-1', { id: 'installment-1', resolvedAmount: '60.00' }], + ['installment-2', { id: 'installment-2', resolvedAmount: '40.00' }], + ]); + const updates: Array<{ table: unknown; values: Record }> = []; + const audits: Array> = []; + const tx = { + select: vi.fn().mockImplementation(() => ({ + from: vi.fn((table: unknown) => ({ + where: vi.fn(() => { + if (table === bookingRequestPaymentAllocations) { + return Promise.resolve(allocationRows); + } + if (table === payments) return Promise.resolve(childRows); + if (table === bookingRequestInstallments) { + return { + for: vi.fn().mockResolvedValue([...installmentRows.values()]), + }; + } + return Promise.resolve([]); + }), + })), + })), + update: vi.fn((table: unknown) => ({ + set: vi.fn((values: Record) => { + updates.push({ table, values }); + return { where: vi.fn().mockResolvedValue(undefined) }; + }), + })), + delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })), + insert: vi.fn((table: unknown) => ({ + values: vi.fn((values: Record) => { + if (table === auditLogs) audits.push(values); + return Promise.resolve(undefined); + }), + })), + }; + + await reconcileBookingRequestPaymentAllocations(tx, { + bookingRequestId: 'request-1', + propertyId: 'property-1', + payment: { id: 'payment-1', amount: '100.00' }, + }); + + expect(updates).toEqual(expect.arrayContaining([ + expect.objectContaining({ + table: bookingRequestPaymentAllocations, + values: { amount: '10.00' }, + }), + expect.objectContaining({ + table: bookingRequestInstallments, + values: expect.objectContaining({ allocatedAmount: '10.00', status: 'partial' }), + }), + ])); + expect(audits).toEqual([ + expect.objectContaining({ + bookingRequestId: 'request-1', + entityType: 'booking_request_payment_allocation', + }), + ]); + }); +}); diff --git a/apps/api/src/modules/booking-request/booking-request-allocation-reconciler.ts b/apps/api/src/modules/booking-request/booking-request-allocation-reconciler.ts new file mode 100644 index 00000000..1728e143 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-allocation-reconciler.ts @@ -0,0 +1,185 @@ +import Decimal from 'decimal.js'; +import { and, eq } from 'drizzle-orm'; +import { + auditLogs, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + payments, +} from './booking-request-db.js'; +import type { AuditActor } from '../../common/audit/audit-actor'; +import { actorFields } from '../../common/audit/audit-actor'; +import { remainingCapturedAmount } from '../payment/payment-ledger'; + +export type ReconciledAllocationInput = { + id: string; + installmentId: string; + amount: string; + createdAt?: Date | null; +}; + +export type NetAllocationReconciliation = { + allocationAmounts: Map; + installmentTotals: Map; +}; + +/** + * Preserve the oldest allocation evidence first and release newest allocations + * deterministically when a return reduces a movement's net captured value. + */ +export function planNetAllocationReconciliation( + netCapturedAmount: string, + allocations: readonly ReconciledAllocationInput[], +): NetAllocationReconciliation { + let remaining = Decimal.max(new Decimal(netCapturedAmount), 0); + const sorted = [...allocations].sort((left, right) => { + const time = (left.createdAt?.getTime() ?? 0) - (right.createdAt?.getTime() ?? 0); + return time !== 0 ? time : left.id.localeCompare(right.id); + }); + const allocationAmounts = new Map(); + const installmentTotals = new Map(); + for (const allocation of sorted) { + const existing = new Decimal(allocation.amount); + const retained = Decimal.min(existing, remaining); + const amount = retained.toFixed(2); + allocationAmounts.set(allocation.id, amount); + installmentTotals.set( + allocation.installmentId, + new Decimal(installmentTotals.get(allocation.installmentId) ?? 0) + .plus(retained) + .toFixed(2), + ); + remaining = Decimal.max(remaining.minus(retained), 0); + } + return { allocationAmounts, installmentTotals }; +} + +type ReconciliationPayment = { id: string; amount: string }; + +/** + * Apply a movement's net captured capacity to its allocation evidence. The + * caller must already hold the Booking Request and parent-payment locks, so + * installment locks are always acquired last. + */ +export async function reconcileBookingRequestPaymentAllocations( + tx: any, + input: { + bookingRequestId: string; + propertyId: string; + payment: ReconciliationPayment; + actor?: AuditActor; + }, +): Promise { + const allRows = await tx + .select() + .from(bookingRequestPaymentAllocations) + .where(and( + eq(bookingRequestPaymentAllocations.bookingRequestId, input.bookingRequestId), + eq(bookingRequestPaymentAllocations.propertyId, input.propertyId), + )); + const allAllocations = (allRows as Array).filter((row) => + row.propertyId === input.propertyId && row.bookingRequestId === input.bookingRequestId); + const movementAllocations = allAllocations.filter((row) => row.paymentId === input.payment.id); + if (movementAllocations.length === 0) return; + + const childRows = await tx + .select() + .from(payments) + .where(and( + eq(payments.originalPaymentId, input.payment.id), + eq(payments.bookingRequestId, input.bookingRequestId), + eq(payments.propertyId, input.propertyId), + eq(payments.status, 'captured'), + )); + const children = (childRows as Array<{ + originalPaymentId: string | null; + bookingRequestId: string | null; + propertyId: string; + status: string; + amount: string; + }>).filter((row) => + row.originalPaymentId === input.payment.id + && row.bookingRequestId === input.bookingRequestId + && row.propertyId === input.propertyId + && row.status === 'captured'); + const netCaptured = remainingCapturedAmount(input.payment.amount, children); + const plan = planNetAllocationReconciliation( + netCaptured.toFixed(2), + movementAllocations, + ); + const affectedInstallments = new Set(); + + for (const allocation of movementAllocations) { + const nextAmount = plan.allocationAmounts.get(allocation.id) ?? '0.00'; + if (new Decimal(nextAmount).eq(allocation.amount)) continue; + affectedInstallments.add(allocation.installmentId); + if (new Decimal(nextAmount).isZero()) { + await tx + .delete(bookingRequestPaymentAllocations) + .where(and( + eq(bookingRequestPaymentAllocations.id, allocation.id), + eq(bookingRequestPaymentAllocations.propertyId, input.propertyId), + eq(bookingRequestPaymentAllocations.bookingRequestId, input.bookingRequestId), + )); + } else { + await tx + .update(bookingRequestPaymentAllocations) + .set({ amount: nextAmount }) + .where(and( + eq(bookingRequestPaymentAllocations.id, allocation.id), + eq(bookingRequestPaymentAllocations.propertyId, input.propertyId), + eq(bookingRequestPaymentAllocations.bookingRequestId, input.bookingRequestId), + )); + } + await tx.insert(auditLogs).values({ + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + action: new Decimal(nextAmount).isZero() ? 'delete' : 'update', + entityType: 'booking_request_payment_allocation', + entityId: allocation.id, + ...actorFields(input.actor), + previousValue: { amount: allocation.amount }, + newValue: { amount: nextAmount, reason: 'payment_net_reduced' }, + description: 'Booking request allocation reduced after payment return', + }); + } + + if (affectedInstallments.size === 0) return; + const allAfter = allAllocations + .map((allocation) => allocation.paymentId === input.payment.id + ? { ...allocation, amount: plan.allocationAmounts.get(allocation.id) ?? '0.00' } + : allocation) + .filter((allocation) => new Decimal(allocation.amount).gt(0)); + for (const installmentId of affectedInstallments) { + const rows = await tx + .select() + .from(bookingRequestInstallments) + .where(and( + eq(bookingRequestInstallments.id, installmentId), + eq(bookingRequestInstallments.propertyId, input.propertyId), + eq(bookingRequestInstallments.bookingRequestId, input.bookingRequestId), + )) + .for('update'); + const installment = rows.find((row: typeof bookingRequestInstallments.$inferSelect) => + row.id === installmentId); + if (!installment?.resolvedAmount) { + throw new Error(`Installment ${installmentId} has no resolved amount`); + } + const allocated = allAfter + .filter((row) => row.installmentId === installmentId) + .reduce((sum, row) => sum.plus(row.amount), new Decimal(0)); + const resolved = new Decimal(installment.resolvedAmount); + const status = allocated.isZero() ? 'unpaid' : allocated.gte(resolved) ? 'paid' : 'partial'; + await tx + .update(bookingRequestInstallments) + .set({ allocatedAmount: allocated.toFixed(2), status, updatedAt: new Date() }) + .where(and( + eq(bookingRequestInstallments.id, installmentId), + eq(bookingRequestInstallments.propertyId, input.propertyId), + eq(bookingRequestInstallments.bookingRequestId, input.bookingRequestId), + )); + } +} diff --git a/apps/api/src/modules/booking-request/booking-request-amendment-pricing.spec.ts b/apps/api/src/modules/booking-request/booking-request-amendment-pricing.spec.ts new file mode 100644 index 00000000..aae01bf4 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-amendment-pricing.spec.ts @@ -0,0 +1,231 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import type { AcceptedPricingSnapshot } from './booking-request-db.js'; +import { + buildAmendedPricingSnapshot, + buildPriorAmendedPricingSnapshot, + withoutCancelledAcceptedServices, +} from './booking-request-amendment-pricing'; + +const previous: AcceptedPricingSnapshot = { + version: 1, + source: 'custom', + currencyCode: 'EUR', + grandTotal: '253.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: [ + { + serviceId: 'breakfast', + code: 'BREAKFAST', + name: 'Breakfast', + postingRule: 'per_night', + chargeType: 'food_beverage', + currencyCode: 'EUR', + unitPrice: '15.00', + quantity: 2, + lineTotal: '30.00', + taxTotal: '3.00', + lineItems: [ + { date: '2026-10-01', amount: '15.00', taxAmount: '1.50' }, + { date: '2026-10-02', amount: '15.00', taxAmount: '1.50' }, + ], + }, + { + serviceId: 'parking', + code: 'PARKING', + name: '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: '50.00', + servicesTaxTotal: '5.00', + customReason: 'Written offer', + adjustment: { + amount: '-22.00', + reason: 'Written offer', + serviceDate: '2026-10-01', + }, +}; + +const currentQuote = { + currencyCode: 'EUR', + grandTotal: '396.00', + roomTotal: '330.00', + taxTotal: '33.00', + lineItems: [ + { date: '2026-10-01', rate: '110.00', tax: '11.00' }, + { date: '2026-10-02', rate: '110.00', tax: '11.00' }, + { date: '2026-10-03', rate: '110.00', tax: '11.00' }, + ], + servicesTotal: '30.00', + servicesTaxTotal: '3.00', + services: [{ + serviceId: 'breakfast', + code: 'BREAKFAST', + name: 'Breakfast', + postingRule: 'per_night', + chargeType: 'food_beverage', + currencyCode: 'EUR', + unitPrice: '10.00', + quantity: 3, + lineTotal: '30.00', + taxTotal: '3.00', + lineItems: [ + { date: '2026-10-01', amount: '10.00', tax: '1.00' }, + { date: '2026-10-02', amount: '10.00', tax: '1.00' }, + { date: '2026-10-03', amount: '10.00', tax: '1.00' }, + ], + }], +}; + +describe('Booking Request prior amendment pricing', () => { + it('removes cancelled operational services and recomputes every aggregate exactly', () => { + const operational = withoutCancelledAcceptedServices(previous, new Set(['parking'])); + + expect(operational.services.map((service) => service.serviceId)).toEqual(['breakfast']); + expect(operational).toMatchObject({ + servicesTotal: '30.00', + servicesTaxTotal: '3.00', + grandTotal: '231.00', + }); + expect(previous.services).toHaveLength(2); + }); + + it('preserves overlap and clones the nearest immutable boundary basis for extension nights', () => { + const amended = buildPriorAmendedPricingSnapshot( + previous, + '2026-09-30', + '2026-10-04', + ); + + expect(amended).toMatchObject({ + source: 'prior', + currencyCode: 'EUR', + roomTotal: '400.00', + taxTotal: '40.00', + servicesTotal: '80.00', + servicesTaxTotal: '8.00', + grandTotal: '506.00', + adjustment: { + amount: '-22.00', + reason: 'Written offer', + serviceDate: '2026-10-01', + }, + }); + expect(amended.nights).toEqual([ + { date: '2026-09-30', roomAmount: '100.00', taxAmount: '10.00' }, + previous.nights[0], + previous.nights[1], + { date: '2026-10-03', roomAmount: '100.00', taxAmount: '10.00' }, + ]); + expect(amended.services[0]?.lineItems).toEqual([ + { date: '2026-09-30', amount: '15.00', taxAmount: '1.50' }, + previous.services[0]!.lineItems[0], + previous.services[0]!.lineItems[1], + { date: '2026-10-03', amount: '15.00', taxAmount: '1.50' }, + ]); + expect(amended.services[1]).toMatchObject({ + postingRule: 'once', + quantity: 1, + lineTotal: '20.00', + taxTotal: '2.00', + }); + expect(amended.services[1]?.lineItems).toEqual([ + previous.services[1]!.lineItems[0], + ]); + }); + + it('removes omitted nightly basis and reanchors fixed once/adjustment lines when shortening', () => { + const amended = buildPriorAmendedPricingSnapshot( + previous, + '2026-10-02', + '2026-10-03', + ); + + expect(amended).toMatchObject({ + roomTotal: '100.00', + taxTotal: '10.00', + servicesTotal: '35.00', + servicesTaxTotal: '3.50', + grandTotal: '126.50', + adjustment: { amount: '-22.00', serviceDate: '2026-10-02' }, + }); + expect(amended.services[0]?.lineItems).toEqual([ + previous.services[0]!.lineItems[1], + ]); + expect(amended.services[1]?.lineItems).toEqual([ + { date: '2026-10-02', amount: '20.00', taxAmount: '2.00' }, + ]); + }); +}); + +describe('Booking Request current/custom amendment pricing', () => { + it('normalizes the authoritative amended quote for the current choice', () => { + const amended = buildAmendedPricingSnapshot({ + source: 'current', + previous, + currentQuote, + currencyCode: 'EUR', + arrivalDate: '2026-10-01', + departureDate: '2026-10-04', + }); + + expect(amended).toMatchObject({ + source: 'current', + grandTotal: '396.00', + adjustment: null, + }); + expect(amended.nights[0]).toEqual({ + date: '2026-10-01', + roomAmount: '110.00', + taxAmount: '11.00', + }); + }); + + it('requires an exact positive custom total and reason in the reservation currency', () => { + expect(() => buildAmendedPricingSnapshot({ + source: 'custom', + previous, + currentQuote, + currencyCode: 'EUR', + arrivalDate: '2026-10-01', + departureDate: '2026-10-04', + customTotal: '390.00', + customReason: ' Matched signed offer ', + })).not.toThrow(); + + expect(() => buildAmendedPricingSnapshot({ + source: 'custom', + previous, + currentQuote, + currencyCode: 'EUR', + arrivalDate: '2026-10-01', + departureDate: '2026-10-04', + customTotal: '0.00', + customReason: 'No', + })).toThrow(BadRequestException); + + expect(() => buildAmendedPricingSnapshot({ + source: 'current', + previous, + currentQuote: { ...currentQuote, currencyCode: 'USD' }, + currencyCode: 'EUR', + arrivalDate: '2026-10-01', + departureDate: '2026-10-04', + })).toThrow(ConflictException); + }); +}); diff --git a/apps/api/src/modules/booking-request/booking-request-amendment-pricing.ts b/apps/api/src/modules/booking-request/booking-request-amendment-pricing.ts new file mode 100644 index 00000000..13340cec --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-amendment-pricing.ts @@ -0,0 +1,196 @@ +import { ConflictException } from '@nestjs/common'; +import type { + AcceptedPricingService, + AcceptedPricingServiceNight, + AcceptedPricingSnapshot, +} from './booking-request-db.js'; +import Decimal from 'decimal.js'; +import { stayDates } from '../reservation/availability.service'; +import { buildAcceptedPricingSnapshot } from './booking-request-pricing'; + +export type StayAmendmentPriceSource = 'prior' | 'current' | 'custom'; + +type BuildAmendedPricingInput = { + source: StayAmendmentPriceSource; + previous: AcceptedPricingSnapshot; + currentQuote: unknown; + currencyCode: string; + arrivalDate: string; + departureDate: string; + customTotal?: string; + customReason?: string; +}; + +function nearestBoundaryLine(lines: T[], date: string): T { + const ordered = [...lines].sort((left, right) => left.date.localeCompare(right.date)); + const exact = ordered.find((line) => line.date === date); + if (exact) return exact; + const boundary = date < ordered[0]!.date ? ordered[0] : ordered.at(-1); + if (!boundary) throw new ConflictException('Prior pricing has no immutable posting basis'); + return boundary; +} + +function sum( + values: Array, +): string { + return values.reduce((total, value) => total.plus(value), new Decimal(0)).toFixed(2); +} + +/** + * Convert the immutable accepted snapshot into the current operational basis. + * Cancellation is authoritative for scheduling, so its money must leave every + * aggregate before prior/current/custom amendment choices are calculated. + */ +export function withoutCancelledAcceptedServices( + previous: AcceptedPricingSnapshot, + cancelledServiceIds: ReadonlySet, +): AcceptedPricingSnapshot { + const services = previous.services + .filter((service) => !cancelledServiceIds.has(service.serviceId)) + .map((service) => structuredClone(service)); + const servicesTotal = sum(services.map((service) => service.lineTotal)); + const servicesTaxTotal = sum(services.map((service) => service.taxTotal)); + const grandTotal = new Decimal(previous.roomTotal) + .plus(previous.taxTotal) + .plus(servicesTotal) + .plus(servicesTaxTotal) + .plus(previous.adjustment?.amount ?? 0) + .toFixed(2); + return { + ...structuredClone(previous), + services, + servicesTotal, + servicesTaxTotal, + grandTotal, + }; +} + +function priorService( + service: AcceptedPricingService, + dates: string[], +): AcceptedPricingService { + if (service.currencyCode.length !== 3) { + throw new ConflictException(`Prior service ${service.code} has no valid currency`); + } + if (service.postingRule === 'on_consumption') return structuredClone(service); + if (!service.lineItems.length) { + throw new ConflictException(`Prior service ${service.code} has no immutable posting basis`); + } + + let lineItems: AcceptedPricingServiceNight[]; + if (service.postingRule === 'per_night') { + lineItems = dates.map((date) => { + const basis = nearestBoundaryLine(service.lineItems, date); + return { ...basis, date }; + }); + } else { + const basis = service.lineItems[0]!; + lineItems = [{ + ...basis, + date: dates.includes(basis.date) ? basis.date : dates[0]!, + }]; + } + return { + ...structuredClone(service), + quantity: service.postingRule === 'per_night' ? dates.length : service.quantity, + lineTotal: sum(lineItems.map((line) => line.amount)), + taxTotal: sum(lineItems.map((line) => line.taxAmount)), + lineItems, + }; +} + +/** + * Derive a prior-rate stay without consulting live catalog state. + * Existing dates keep their exact immutable lines. A date extending before or + * after the old window copies the nearest accepted boundary line. Per-night + * services use the same rule; fixed service/adjustment amounts stay fixed and + * move to the new arrival only when their old service date was removed. + */ +export function buildPriorAmendedPricingSnapshot( + previous: AcceptedPricingSnapshot, + arrivalDate: string, + departureDate: string, +): AcceptedPricingSnapshot { + const dates = stayDates(arrivalDate, departureDate); + if (!previous.nights?.length) { + throw new ConflictException('Reservation has no immutable prior nightly basis'); + } + const nights = dates.map((date) => { + const basis = nearestBoundaryLine(previous.nights, date); + return { ...basis, date }; + }); + const services = previous.services.map((service) => { + if (service.currencyCode !== previous.currencyCode) { + throw new ConflictException( + `Prior service currency ${service.currencyCode} does not match ${previous.currencyCode}`, + ); + } + return priorService(service, dates); + }); + const roomTotal = sum(nights.map((night) => night.roomAmount)); + const taxTotal = sum(nights.map((night) => night.taxAmount)); + const servicesTotal = sum(services.map((service) => service.lineTotal)); + const servicesTaxTotal = sum(services.map((service) => service.taxTotal)); + const adjustment = previous.adjustment + ? { + ...structuredClone(previous.adjustment), + serviceDate: dates.includes(previous.adjustment.serviceDate) + ? previous.adjustment.serviceDate + : dates[0]!, + } + : null; + const grandTotal = new Decimal(roomTotal) + .plus(taxTotal) + .plus(servicesTotal) + .plus(servicesTaxTotal) + .plus(adjustment?.amount ?? 0) + .toFixed(2); + if (new Decimal(grandTotal).lessThanOrEqualTo(0)) { + throw new ConflictException('Prior pricing produces a non-positive amended total'); + } + return { + version: 1, + source: 'prior', + currencyCode: previous.currencyCode, + grandTotal, + roomTotal, + taxTotal, + nights, + services, + servicesTotal, + servicesTaxTotal, + customReason: previous.customReason, + adjustment, + }; +} + +export function buildAmendedPricingSnapshot( + input: BuildAmendedPricingInput, +): AcceptedPricingSnapshot { + const quoteCurrency = ( + input.currentQuote && typeof input.currentQuote === 'object' + ? (input.currentQuote as Record)['currencyCode'] + : undefined + ); + if (input.previous.currencyCode !== input.currencyCode) { + throw new ConflictException('Prior pricing currency does not match the reservation currency'); + } + if (quoteCurrency !== input.currencyCode) { + throw new ConflictException('Current quote currency does not match the reservation currency'); + } + if (input.source === 'prior') { + return buildPriorAmendedPricingSnapshot( + input.previous, + input.arrivalDate, + input.departureDate, + ); + } + return buildAcceptedPricingSnapshot({ + source: input.source, + requestCurrencyCode: input.currencyCode, + submittedQuote: input.currentQuote, + currentQuote: input.currentQuote, + customTotal: input.customTotal, + customReason: input.customReason, + }); +} diff --git a/apps/api/src/modules/booking-request/booking-request-amendment.spec.ts b/apps/api/src/modules/booking-request/booking-request-amendment.spec.ts new file mode 100644 index 00000000..aa9ed234 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-amendment.spec.ts @@ -0,0 +1,796 @@ +import { + BadRequestException, + ConflictException, + NotFoundException, + ValidationPipe, +} from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { + auditLogs, + bookingRequestConsequences, + bookingRequests, + bookingRequestStayAmendments, + properties, + reservationServices, + reservations, +} from './booking-request-db.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Reflector } from '@nestjs/core'; +import type { AcceptedPricingSnapshot } from './booking-request-db.js'; +import { PERMISSIONS_KEY } from '../auth/permissions.decorator'; +import { BookingRequestController } from './booking-request.controller'; +import { + amendmentPreviewFingerprint, + BookingRequestService, +} from './booking-request.service'; +import { + AmendBookingRequestStayDto, + PreviewBookingRequestStayAmendmentDto, +} from './dto/amend-booking-request-stay.dto'; + +const PROPERTY = 'aaaaaaaa-0000-4000-a000-000000000001'; +const OTHER_PROPERTY = 'aaaaaaaa-0000-4000-a000-000000000002'; +const REQUEST = 'bbbbbbbb-0000-4000-a000-000000000001'; +const RESERVATION = 'cccccccc-0000-4000-a000-000000000001'; +const FOLIO = 'dddddddd-0000-4000-a000-000000000001'; +const ROOM_TYPE = 'eeeeeeee-0000-4000-a000-000000000001'; +const RATE_PLAN = 'ffffffff-0000-4000-a000-000000000001'; + +const acceptedPricing: 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, +}; + +function currentQuote(rate = '120.00', tax = '12.00') { + const dates = ['2026-10-01', '2026-10-02', '2026-10-03']; + return { + propertyId: PROPERTY, + roomTypeId: ROOM_TYPE, + ratePlanId: RATE_PLAN, + checkIn: '2026-10-01', + checkOut: '2026-10-04', + nights: 3, + currencyCode: 'EUR', + lineItems: dates.map((date) => ({ date, rate, tax })), + roomTotal: String((Number(rate) * 3).toFixed(2)), + taxTotal: String((Number(tax) * 3).toFixed(2)), + services: [], + servicesTotal: '0.00', + servicesTaxTotal: '0.00', + grandTotal: String(((Number(rate) + Number(tax)) * 3).toFixed(2)), + }; +} + +function acceptedRequest(status: 'pending' | 'accepted' | 'denied' = 'accepted') { + return { + id: REQUEST, + propertyId: PROPERTY, + submissionIdempotencyKey: 'original-request', + submissionFingerprint: 'original-fingerprint', + status, + arrivalDate: '2026-10-01', + departureDate: '2026-10-03', + roomTypeId: ROOM_TYPE, + ratePlanId: RATE_PLAN, + adults: 2, + children: 0, + guestFirstName: 'Ada', + guestLastName: 'Lovelace', + guestEmail: 'ada@example.com', + guestPhone: null, + specialRequests: null, + serviceIds: [], + formSnapshot: [], + applicationAnswers: {}, + submittedQuoteSnapshot: { + currencyCode: 'EUR', + grandTotal: '220.00', + }, + currentQuoteSnapshot: { + currencyCode: 'EUR', + grandTotal: '220.00', + }, + currencyCode: 'EUR', + setupIntentId: null, + stripeCustomerId: null, + stripePaymentMethodId: null, + cardLastFour: null, + cardBrand: null, + consentText: null, + consentVersion: null, + consentedAt: null, + acceptedPriceSource: 'current', + acceptedTotal: '220.00', + customPriceReason: null, + acceptedReservationId: status === 'accepted' ? RESERVATION : null, + acceptedFolioId: status === 'accepted' ? FOLIO : null, + decidedBy: 'staff-original', + decidedAt: new Date('2026-08-24T10:00:00.000Z'), + denialReason: null, + createdAt: new Date('2026-08-24T09:00:00.000Z'), + updatedAt: new Date('2026-08-24T10:00:00.000Z'), + }; +} + +function linkedReservation() { + return { + id: RESERVATION, + propertyId: PROPERTY, + bookingId: 'booking-1', + guestId: 'guest-1', + arrivalDate: '2026-10-01', + departureDate: '2026-10-03', + nights: 2, + roomTypeId: ROOM_TYPE, + roomId: null, + status: 'confirmed', + ratePlanId: RATE_PLAN, + totalAmount: '220.00', + currencyCode: 'EUR', + acceptedPricingSnapshot: structuredClone(acceptedPricing), + adults: 2, + children: 0, + updatedAt: new Date('2026-08-24T10:05:00.000Z'), + }; +} + +type HarnessState = { + properties: Array>; + requests: Array>; + reservations: Array>; + amendments: Array>; + audits: Array>; + consequences: Array>; + reservationServices: Array>; +}; + +function makeDatabase(state: HarnessState, lockOrder: string[]) { + const rowsFor = (table: unknown) => { + if (table === properties) return state.properties; + if (table === bookingRequests) return state.requests; + if (table === reservations) return state.reservations; + if (table === bookingRequestStayAmendments) return state.amendments; + if (table === auditLogs) return state.audits; + if (table === bookingRequestConsequences) return state.consequences; + if (table === reservationServices) return state.reservationServices; + return []; + }; + let transactionActive = false; + + const selectBuilder = (selection?: Record) => { + let table: unknown; + let limit: number | undefined; + const resolveRows = () => { + const rows = structuredClone(rowsFor(table)); + if (selection && Object.keys(selection).length === 1 && 'count' in selection) { + return [{ count: rows.length }]; + } + return limit == null ? rows : rows.slice(0, limit); + }; + const chain: any = { + from: vi.fn((value: unknown) => { table = value; return chain; }), + where: vi.fn(() => chain), + orderBy: vi.fn(() => chain), + limit: vi.fn((value: number) => { limit = value; return chain; }), + for: vi.fn(async () => { + if (table === properties) lockOrder.push('property'); + else if (table === bookingRequests) lockOrder.push('request'); + else if ( + table === reservations + && selection + && Object.keys(selection).length === 1 + && 'id' in selection + ) lockOrder.push('pricing-lock'); + else if (table === reservations) lockOrder.push('reservation'); + return resolveRows(); + }), + then: (resolve: (value: unknown) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve(resolveRows()).then(resolve, reject), + }; + return chain; + }; + + const db: any = { + execute: vi.fn(async () => undefined), + select: vi.fn((selection?: Record) => selectBuilder(selection)), + insert: vi.fn((table: unknown) => ({ + values: vi.fn((values: Record) => { + let row: Record | undefined; + const execute = () => { + if (row) return row; + row = { + id: values.id ?? `${table === bookingRequestStayAmendments ? 'amendment' : 'row'}-${rowsFor(table).length + 1}`, + ...structuredClone(values), + }; + rowsFor(table).push(row); + return row; + }; + const builder: any = { + returning: vi.fn(async () => [execute()]), + then: (resolve: (value: unknown) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve().then(() => execute()).then(() => undefined).then(resolve, reject), + }; + return builder; + }), + })), + update: vi.fn((table: unknown) => ({ + set: vi.fn((changes: Record) => ({ + where: vi.fn(() => { + const apply = () => { + for (const row of rowsFor(table)) Object.assign(row, structuredClone(changes)); + return structuredClone(rowsFor(table)); + }; + const builder: any = { + returning: vi.fn(async () => apply()), + then: (resolve: (value: unknown) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve().then(() => apply()).then(() => undefined).then(resolve, reject), + }; + return builder; + }), + })), + })), + delete: vi.fn(() => { throw new Error('Stay amendments never delete business records'); }), + }; + db.transaction = vi.fn(async (callback: (tx: any) => Promise) => { + const snapshot = structuredClone(state); + transactionActive = true; + try { + return await callback(db); + } catch (error) { + Object.assign(state, snapshot); + throw error; + } finally { + transactionActive = false; + } + }); + return { db, isTransactionActive: () => transactionActive }; +} + +function makeHarness(status: 'pending' | 'accepted' | 'denied' = 'accepted') { + const state: HarnessState = { + properties: [{ id: PROPERTY, currencyCode: 'EUR' }], + requests: [acceptedRequest(status)], + reservations: status === 'accepted' ? [linkedReservation()] : [], + amendments: [], + audits: [], + consequences: [], + reservationServices: [], + }; + const lockOrder: string[] = []; + const database = makeDatabase(state, lockOrder); + let quote = currentQuote(); + const quoteTransactionStates: boolean[] = []; + const bookingEngine = { + quote: vi.fn(async () => { + quoteTransactionStates.push(database.isTransactionActive()); + return structuredClone(quote); + }), + }; + const reservation = { + lockInventory: vi.fn(async () => { lockOrder.push('inventory'); }), + modifyAcceptedStay: vi.fn(async ( + locked: Record, + propertyId: string, + dto: Record, + pricing: AcceptedPricingSnapshot, + ) => { + const row = state.reservations.find((candidate) => + candidate.id === locked.id && candidate.propertyId === propertyId)!; + const previous = structuredClone(row); + Object.assign(row, { + arrivalDate: dto.arrivalDate, + departureDate: dto.departureDate, + nights: pricing.nights.length, + totalAmount: pricing.grandTotal, + acceptedPricingSnapshot: structuredClone(pricing), + updatedAt: new Date('2026-08-25T12:00:00.000Z'), + }); + return { + reservation: structuredClone(row), + previousArrivalDate: previous.arrivalDate, + previousDepartureDate: previous.departureDate, + previousTotalAmount: previous.totalAmount, + newTotalAmount: row.totalAmount, + }; + }), + }; + const folio = { + reconcileAcceptedStayAmendment: vi.fn(async () => ({ + reversedChargeIds: [], + adjustmentAmount: '44.00', + })), + }; + const dispatchTransactionStates: boolean[] = []; + const webhook = { + dispatchPersisted: vi.fn(async () => { + dispatchTransactionStates.push(database.isTransactionActive()); + }), + emit: vi.fn(), + }; + const mailer = { + queue: vi.fn(() => { throw new Error('Stay amendments do not send email'); }), + deliverForRequestBestEffort: vi.fn(() => { throw new Error('Stay amendments do not send email'); }), + }; + const service = new BookingRequestService( + database.db, + {} as any, + bookingEngine as any, + {} as any, + {} as any, + {} as any, + webhook as any, + {} as any, + reservation as any, + folio as any, + {} as any, + mailer as any, + ); + return { + service, + state, + lockOrder, + bookingEngine, + reservation, + folio, + webhook, + mailer, + quoteTransactionStates, + dispatchTransactionStates, + setQuote(next: ReturnType) { quote = next; }, + }; +} + +const dates = { + arrivalDate: '2026-10-01', + departureDate: '2026-10-04', +}; + +describe('Booking Request stay amendment DTOs', () => { + it('requires canonical dates, a fingerprint, a durable key, and custom reason', async () => { + const invalid = plainToInstance(AmendBookingRequestStayDto, { + arrivalDate: '2026-10-01T10:00:00.000Z', + departureDate: '2026-10-01', + priceSource: 'custom', + previewToken: 'not-a-token', + idempotencyKey: '', + customTotal: '100.00', + }); + const errors = await validate(invalid); + expect(errors.map((error) => error.property)).toEqual(expect.arrayContaining([ + 'arrivalDate', 'previewToken', 'idempotencyKey', + ])); + + const preview = plainToInstance(PreviewBookingRequestStayAmendmentDto, { + propertyId: PROPERTY, + arrivalDate: '2026-10-01', + departureDate: 'invalid', + }); + expect((await validate(preview)).map((error) => error.property)).toContain('departureDate'); + }); + + it('accepts the real preview query through the global whitelist pipe and controller', async () => { + const stayAmendmentPreview = vi.fn().mockResolvedValue({ previewToken: 'token' }); + const controller = new BookingRequestController( + { stayAmendmentPreview } as any, + {} as any, + {} as any, + ); + const query = await new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }).transform({ propertyId: PROPERTY, ...dates }, { + type: 'query', + metatype: PreviewBookingRequestStayAmendmentDto, + }); + + await controller.stayAmendmentPreview(REQUEST, query.propertyId, query); + + expect(stayAmendmentPreview).toHaveBeenCalledWith( + REQUEST, + PROPERTY, + expect.objectContaining({ propertyId: PROPERTY, ...dates }), + ); + }); +}); + +describe('BookingRequestService stay amendment preview', () => { + it('exposes the linked operational stay without mutating the original requested deal', async () => { + const detail = await makeHarness().service.findById(REQUEST, PROPERTY); + + expect(detail).toMatchObject({ + arrivalDate: '2026-10-01', + departureDate: '2026-10-03', + acceptedTotal: '220.00', + operationalReservation: { + id: RESERVATION, + arrivalDate: '2026-10-01', + departureDate: '2026-10-03', + totalAmount: '220.00', + currencyCode: 'EUR', + }, + }); + }); + + it('returns prior and authoritative current totals with a reservation-state fingerprint', async () => { + const harness = makeHarness(); + const preview = await harness.service.stayAmendmentPreview( + REQUEST, + PROPERTY, + dates, + ); + + expect(preview).toEqual(expect.objectContaining({ + requestId: REQUEST, + reservationId: RESERVATION, + previousArrivalDate: '2026-10-01', + previousDepartureDate: '2026-10-03', + previousTotal: '220.00', + arrivalDate: '2026-10-01', + departureDate: '2026-10-04', + priorTotal: '330.00', + currentTotal: '396.00', + currencyCode: 'EUR', + previewVersion: 1, + previewToken: expect.stringMatching(/^v1:[a-f0-9]{64}$/), + })); + expect(harness.bookingEngine.quote).toHaveBeenCalledWith( + PROPERTY, + expect.objectContaining({ checkIn: dates.arrivalDate, checkOut: dates.departureDate }), + undefined, + { excludeReservationId: RESERVATION }, + ); + }); + + it('excludes a cancelled accepted service from prior/current operational pricing', async () => { + const harness = makeHarness(); + const serviceLine = { + serviceId: 'parking', code: 'PARKING', name: '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' }], + }; + Object.assign(harness.state.reservations[0]!, { + totalAmount: '242.00', + acceptedPricingSnapshot: { + ...structuredClone(acceptedPricing), services: [serviceLine], + servicesTotal: '20.00', servicesTaxTotal: '2.00', grandTotal: '242.00', + }, + }); + harness.state.requests[0]!.serviceIds = ['parking']; + harness.state.reservationServices.push({ + id: 'rs-parking', propertyId: PROPERTY, reservationId: RESERVATION, + serviceId: 'parking', status: 'cancelled', sourceChannel: 'booking_engine', + createdAt: new Date('2026-08-24T10:05:00.000Z'), + }, { + id: 'rs-parking-frontdesk', propertyId: PROPERTY, reservationId: RESERVATION, + serviceId: 'parking', status: 'confirmed', sourceChannel: 'front_desk', + createdAt: new Date('2026-08-25T10:05:00.000Z'), + }); + + const preview = await harness.service.stayAmendmentPreview(REQUEST, PROPERTY, dates); + + expect(preview).toMatchObject({ previousTotal: '220.00', priorTotal: '330.00' }); + expect(harness.bookingEngine.quote).toHaveBeenLastCalledWith( + PROPERTY, + expect.objectContaining({ serviceIds: [] }), + undefined, + { excludeReservationId: RESERVATION }, + ); + }); + + it('rejects pending/denied requests, wrong property scope, and unavailable complete windows', async () => { + await expect(makeHarness('pending').service.stayAmendmentPreview(REQUEST, PROPERTY, dates)) + .rejects.toThrow(ConflictException); + await expect(makeHarness('denied').service.stayAmendmentPreview(REQUEST, PROPERTY, dates)) + .rejects.toThrow(ConflictException); + await expect(makeHarness().service.stayAmendmentPreview(REQUEST, OTHER_PROPERTY, dates)) + .rejects.toThrow(NotFoundException); + + const unavailable = makeHarness(); + unavailable.bookingEngine.quote.mockRejectedValueOnce( + new BadRequestException('No availability for room type on 2026-10-03'), + ); + await expect(unavailable.service.stayAmendmentPreview(REQUEST, PROPERTY, dates)) + .rejects.toThrow(ConflictException); + }); + + it('shows only request-linked stay amendments in the request audit timeline', async () => { + const harness = makeHarness(); + const occurredAt = new Date('2026-08-25T10:00:00.000Z'); + harness.state.audits.push( + { + id: 'request-amendment-audit', + propertyId: PROPERTY, + bookingRequestId: REQUEST, + action: 'update', + entityType: 'reservation', + entityId: RESERVATION, + userId: 'staff-1', + userEmail: 'staff@example.com', + previousValue: { arrivalDate: '2026-10-01' }, + newValue: { amendmentId: 'amendment-1', arrivalDate: '2026-10-02' }, + description: 'Accepted Booking Request stay amended', + occurredAt, + occurredAtMicros: '1787652000000000', + }, + { + id: 'generic-reservation-audit', + propertyId: PROPERTY, + bookingRequestId: null, + action: 'update', + entityType: 'reservation', + entityId: RESERVATION, + userId: 'staff-2', + userEmail: 'other@example.com', + previousValue: { status: 'confirmed' }, + newValue: { status: 'checked_in' }, + description: 'Reservation checked in', + occurredAt, + occurredAtMicros: '1787652000000001', + }, + ); + + const history = await harness.service.auditHistory(REQUEST, PROPERTY); + + expect(history.data.map((row) => row.id)).toEqual(['request-amendment-audit']); + expect(history.data[0]).toMatchObject({ summary: 'stay.amended' }); + }); +}); + +describe('Booking Request stay amendment HTTP contract', () => { + it('exposes read preview and write commit endpoints with runtime DTO metadata', () => { + const reflector = new Reflector(); + expect(reflector.get( + PERMISSIONS_KEY, + BookingRequestController.prototype.stayAmendmentPreview, + )).toEqual(['reservations.read']); + expect(reflector.get( + PERMISSIONS_KEY, + BookingRequestController.prototype.amendStay, + )).toEqual(['reservations.write']); + expect(Reflect.getMetadata( + 'design:paramtypes', + BookingRequestController.prototype, + 'stayAmendmentPreview', + )).toContain(PreviewBookingRequestStayAmendmentDto); + expect(Reflect.getMetadata( + 'design:paramtypes', + BookingRequestController.prototype, + 'amendStay', + )).toContain(AmendBookingRequestStayDto); + }); +}); + +describe('BookingRequestService accepted stay amendment commit', () => { + beforeEach(() => vi.clearAllMocks()); + + it('commits without a cancelled accepted service so total and posting snapshot stay aligned', async () => { + const harness = makeHarness(); + const serviceLine = { + serviceId: 'parking', code: 'PARKING', name: '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' }], + }; + Object.assign(harness.state.reservations[0]!, { + totalAmount: '242.00', + acceptedPricingSnapshot: { + ...structuredClone(acceptedPricing), services: [serviceLine], + servicesTotal: '20.00', servicesTaxTotal: '2.00', grandTotal: '242.00', + }, + }); + harness.state.requests[0]!.serviceIds = ['parking']; + harness.state.reservationServices.push({ + id: 'rs-parking', propertyId: PROPERTY, reservationId: RESERVATION, + serviceId: 'parking', status: 'cancelled', + }); + const preview = await harness.service.stayAmendmentPreview(REQUEST, PROPERTY, dates); + + await harness.service.amendStay(REQUEST, PROPERTY, { + ...dates, + priceSource: 'prior', + previewToken: preview.previewToken, + idempotencyKey: 'cancelled-service-amendment', + }); + + expect(harness.state.reservations[0]).toMatchObject({ + totalAmount: '330.00', + acceptedPricingSnapshot: expect.objectContaining({ + grandTotal: '330.00', services: [], servicesTotal: '0.00', servicesTaxTotal: '0.00', + }), + }); + expect(harness.folio.reconcileAcceptedStayAmendment).toHaveBeenCalledWith( + expect.objectContaining({ + previousPricing: expect.objectContaining({ grandTotal: '242.00', services: [serviceLine] }), + newPricing: expect.objectContaining({ grandTotal: '330.00', services: [] }), + }), + ); + }); + + it('takes the pricing mutex before row locks, updates operational state, and emits one durable event', async () => { + const harness = makeHarness(); + const requestBefore = structuredClone(harness.state.requests[0]); + const preview = await harness.service.stayAmendmentPreview(REQUEST, PROPERTY, dates); + harness.lockOrder.splice(0); + + const result = await harness.service.amendStay( + REQUEST, + PROPERTY, + { + ...dates, + priceSource: 'current', + previewToken: preview.previewToken, + idempotencyKey: 'front-desk-amendment-1', + }, + { userId: 'staff-1', userEmail: 'staff@example.com' }, + ); + + expect(harness.lockOrder).toEqual([ + 'pricing-lock', + 'property', + 'request', + 'reservation', + 'inventory', + ]); + expect(harness.quoteTransactionStates.at(-1)).toBe(true); + expect(harness.bookingEngine.quote).toHaveBeenLastCalledWith( + PROPERTY, + expect.objectContaining({ checkIn: '2026-10-01', checkOut: '2026-10-04' }), + expect.anything(), + { lockForUpdate: true, excludeReservationId: RESERVATION }, + ); + expect(harness.state.requests[0]).toEqual(requestBefore); + expect(harness.state.reservations[0]).toMatchObject({ + arrivalDate: '2026-10-01', + departureDate: '2026-10-04', + totalAmount: '396.00', + acceptedPricingSnapshot: expect.objectContaining({ source: 'current', grandTotal: '396.00' }), + }); + expect(harness.folio.reconcileAcceptedStayAmendment).toHaveBeenCalledWith( + expect.objectContaining({ + propertyId: PROPERTY, + folioId: FOLIO, + reservationId: RESERVATION, + postedBy: 'staff-1', + previousPricing: acceptedPricing, + newPricing: expect.objectContaining({ grandTotal: '396.00' }), + }), + ); + expect(harness.state.amendments).toHaveLength(1); + expect(harness.state.amendments[0]).toMatchObject({ + propertyId: PROPERTY, + bookingRequestId: REQUEST, + reservationId: RESERVATION, + idempotencyKey: 'front-desk-amendment-1', + previousTotalAmount: '220.00', + newTotalAmount: '396.00', + priceSource: 'current', + }); + expect(harness.state.audits).toContainEqual(expect.objectContaining({ + bookingRequestId: REQUEST, + entityType: 'reservation', + entityId: RESERVATION, + userId: 'staff-1', + previousValue: expect.objectContaining({ totalAmount: '220.00' }), + newValue: expect.objectContaining({ + previousArrivalDate: '2026-10-01', + previousDepartureDate: '2026-10-03', + previousTotalAmount: '220.00', + previousPriceSource: 'current', + arrivalDate: '2026-10-01', + departureDate: '2026-10-04', + totalAmount: '396.00', + priceSource: 'current', + reason: null, + }), + })); + expect(harness.state.audits.filter((row) => + row.bookingRequestId === REQUEST && row.entityType === 'reservation')).toHaveLength(1); + expect(harness.state.consequences).toHaveLength(1); + expect(harness.state.consequences[0]).toMatchObject({ + kind: expect.stringMatching(/^amend:/), + status: 'completed', + payload: expect.objectContaining({ + event: 'reservation.modified', + entityId: RESERVATION, + data: expect.objectContaining({ + previousArrivalDate: '2026-10-01', + previousDepartureDate: '2026-10-03', + totalAmount: '396.00', + }), + }), + }); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledTimes(1); + expect(harness.state.audits.filter((row) => + row.bookingRequestId === REQUEST && row.entityType === 'reservation')).toHaveLength(1); + expect(harness.dispatchTransactionStates).toEqual([false]); + expect(harness.mailer.queue).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + amendmentId: harness.state.amendments[0]!.id, + requestId: REQUEST, + reservationId: RESERVATION, + previousTotalAmount: '220.00', + newTotalAmount: '396.00', + }); + }); + + it('replays the same durable operation without duplicating folio, audit, or event effects', async () => { + const harness = makeHarness(); + const preview = await harness.service.stayAmendmentPreview(REQUEST, PROPERTY, dates); + const input = { + ...dates, + priceSource: 'prior' as const, + previewToken: preview.previewToken, + idempotencyKey: 'same-key', + }; + const first = await harness.service.amendStay(REQUEST, PROPERTY, input, { userId: 'staff-1' }); + const second = await harness.service.amendStay(REQUEST, PROPERTY, input, { userId: 'staff-1' }); + + expect(second).toEqual(first); + expect(harness.state.amendments).toHaveLength(1); + expect(harness.reservation.modifyAcceptedStay).toHaveBeenCalledTimes(1); + expect(harness.folio.reconcileAcceptedStayAmendment).toHaveBeenCalledTimes(1); + expect(harness.state.consequences).toHaveLength(1); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledTimes(1); + expect(harness.state.audits.filter((row) => + row.bookingRequestId === REQUEST && row.entityType === 'reservation')).toHaveLength(1); + + await expect(harness.service.amendStay(REQUEST, PROPERTY, { + ...input, + departureDate: '2026-10-05', + }, { userId: 'staff-1' })).rejects.toThrow(/idempotency/i); + }); + + it('rejects a stale preview atomically when the quote changes before commit', async () => { + const harness = makeHarness(); + const preview = await harness.service.stayAmendmentPreview(REQUEST, PROPERTY, dates); + harness.setQuote(currentQuote('125.00', '12.00')); + const before = structuredClone(harness.state); + + await expect(harness.service.amendStay(REQUEST, PROPERTY, { + ...dates, + priceSource: 'current', + previewToken: preview.previewToken, + idempotencyKey: 'stale-preview', + }, { userId: 'staff-1' })).rejects.toThrow(/preview changed/i); + + expect(harness.state).toEqual(before); + expect(harness.reservation.modifyAcceptedStay).not.toHaveBeenCalled(); + expect(harness.folio.reconcileAcceptedStayAmendment).not.toHaveBeenCalled(); + }); + + it('uses the fingerprint helper over full operational state rather than total alone', () => { + const base = { + requestId: REQUEST, + propertyId: PROPERTY, + reservationId: RESERVATION, + reservationUpdatedAt: new Date('2026-08-24T10:05:00.000Z'), + previousArrivalDate: '2026-10-01', + previousDepartureDate: '2026-10-03', + previousTotal: '220.00', + previousPricing: acceptedPricing, + arrivalDate: '2026-10-01', + departureDate: '2026-10-04', + currentQuote: currentQuote(), + }; + const first = amendmentPreviewFingerprint(base); + const changedLine = structuredClone(base); + changedLine.currentQuote.lineItems[0]!.rate = '121.00'; + expect(amendmentPreviewFingerprint(changedLine)).not.toBe(first); + }); +}); 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..8c67730e --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-authorization.spec.ts @@ -0,0 +1,152 @@ +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 { BookingRequestMailerService } from './booking-request-mailer.service'; +import { BookingRequestPaymentService } from './booking-request-payment.service'; +import { BookingRequestController } from './booking-request.controller'; +import { BookingRequestService } from './booking-request.service'; + +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-consequence-worker.service.ts b/apps/api/src/modules/booking-request/booking-request-consequence-worker.service.ts new file mode 100644 index 00000000..3f0eccfa --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-consequence-worker.service.ts @@ -0,0 +1,62 @@ +import { + Inject, + Injectable, + Logger, +} from '@nestjs/common'; +import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { BookingRequestService } from './booking-request.service'; +import { BookingRequestMailerService } from './booking-request-mailer.service'; + +const SCAN_INTERVAL_MS = 30_000; + +/** Startup and recurring recovery driver for the booking-request outbox. */ +@Injectable() +export class BookingRequestConsequenceWorkerService +implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger( + BookingRequestConsequenceWorkerService.name, + ); + private timer?: ReturnType; + private running = false; + + constructor( + @Inject(BookingRequestService) + private readonly bookingRequests: BookingRequestService, + @Inject(BookingRequestMailerService) + private readonly mailer: BookingRequestMailerService, + ) {} + + onModuleInit(): void { + if (process.env['NODE_ENV'] === 'test') return; + void this.runOnce(); + this.timer = setInterval(() => void this.runOnce(), SCAN_INTERVAL_MS); + this.timer.unref(); + } + + onModuleDestroy(): void { + if (this.timer) clearInterval(this.timer); + } + + async runOnce(): Promise { + if (this.running) return; + this.running = true; + try { + await this.bookingRequests.processPendingConsequences(); + } catch (error: unknown) { + this.logger.error( + 'Booking request consequence recovery scan failed', + error instanceof Error ? error.stack : undefined, + ); + } + try { + await this.mailer.processPendingDeliveries(); + } catch (error: unknown) { + this.logger.error( + 'Booking request email recovery scan failed', + error instanceof Error ? error.stack : undefined, + ); + } finally { + this.running = false; + } + } +} diff --git a/apps/api/src/modules/booking-request/booking-request-date.validator.ts b/apps/api/src/modules/booking-request/booking-request-date.validator.ts new file mode 100644 index 00000000..942e882d --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-date.validator.ts @@ -0,0 +1,72 @@ +import { BadRequestException } from '@nestjs/common'; +import { + registerDecorator, + type ValidationArguments, + type ValidationOptions, + ValidatorConstraint, + type ValidatorConstraintInterface, +} from 'class-validator'; + +const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export function isCanonicalCalendarDate(value: unknown): value is string { + if (typeof value !== 'string' || !CALENDAR_DATE_PATTERN.test(value)) return false; + const date = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value; +} + +export function assertCanonicalStayDates(checkIn: unknown, checkOut: unknown): void { + if (!isCanonicalCalendarDate(checkIn) || !isCanonicalCalendarDate(checkOut)) { + throw new BadRequestException('Check-in and check-out must be valid YYYY-MM-DD dates'); + } + if (checkOut <= checkIn) { + throw new BadRequestException('Check-out must be after check-in'); + } +} + +@ValidatorConstraint({ name: 'canonicalCalendarDate', async: false }) +class CanonicalCalendarDateConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + return isCanonicalCalendarDate(value); + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} must be a valid YYYY-MM-DD calendar date`; + } +} + +export function IsCanonicalCalendarDate(validationOptions?: ValidationOptions) { + return (target: object, propertyName: string): void => { + registerDecorator({ + target: target.constructor, + propertyName, + options: validationOptions, + validator: CanonicalCalendarDateConstraint, + }); + }; +} + +@ValidatorConstraint({ name: 'afterCheckIn', async: false }) +class AfterCheckInConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + const checkIn = (args.object as { checkIn?: unknown }).checkIn; + return isCanonicalCalendarDate(checkIn) + && isCanonicalCalendarDate(value) + && value > checkIn; + } + + defaultMessage(): string { + return 'checkOut must be after checkIn'; + } +} + +export function IsAfterCheckIn(validationOptions?: ValidationOptions) { + return (target: object, propertyName: string): void => { + registerDecorator({ + target: target.constructor, + propertyName, + options: validationOptions, + validator: AfterCheckInConstraint, + }); + }; +} diff --git a/apps/api/src/modules/booking-request/booking-request-db.ts b/apps/api/src/modules/booking-request/booking-request-db.ts new file mode 100644 index 00000000..09a5bf34 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-db.ts @@ -0,0 +1,3 @@ +/** Combined Drizzle symbols for the booking-requests module. */ +export * from '@telivityhaip/database'; +export * from '@telivityhaip/booking-requests/schema'; diff --git a/apps/api/src/modules/booking-request/booking-request-decision.spec.ts b/apps/api/src/modules/booking-request/booking-request-decision.spec.ts new file mode 100644 index 00000000..03caeb9e --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-decision.spec.ts @@ -0,0 +1,2258 @@ +import { + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { + auditLogs, + bookingRequestConsequences, + bookingRequestEmailDeliveries, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + bookingRequestPaymentResolutions, + bookingRequests, + bookings, + folios, + guests, + payments, + ratePlanComponents, + reservationGuests, + reservationServices, + reservations, + roomTypes, + services, +} from './booking-request-db.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PERMISSIONS_KEY } from '../auth/permissions.decorator'; +import { AncillaryService } from '../ancillary/ancillary.service'; +import { FolioService } from '../folio/folio.service'; +import { GuestService } from '../guest/guest.service'; +import { ReservationService } from '../reservation/reservation.service'; +import { + acceptancePreviewFingerprint, + BookingRequestService, +} from './booking-request.service'; + +const PROPERTY_ID = 'aaaaaaaa-0000-4000-a000-000000000001'; +const OTHER_PROPERTY_ID = 'aaaaaaaa-0000-4000-a000-000000000002'; +const REQUEST_ID = 'bbbbbbbb-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'; +const PAYMENT_ID = '22222222-0000-4000-a000-000000000001'; + +const submittedQuote = { + propertyId: PROPERTY_ID, + roomTypeId: ROOM_TYPE_ID, + ratePlanId: RATE_PLAN_ID, + checkIn: '2026-10-01', + checkOut: '2026-10-03', + nights: 2, + currencyCode: 'EUR', + lineItems: [ + { date: '2026-10-01', rate: '100.00', tax: '10.00' }, + { date: '2026-10-02', rate: '100.00', tax: '10.00' }, + ], + roomTotal: '200.00', + taxTotal: '20.00', + services: [], + servicesTotal: '0.00', + servicesTaxTotal: '0.00', + grandTotal: '220.00', + depositPolicy: { type: 'none', refundable: true }, + depositDue: '0.00', + cancellationPolicy: { + type: 'flexible', + description: 'Free cancellation before arrival.', + freeCancelHoursBeforeArrival: 24, + }, +}; + +const currentQuote = { + ...structuredClone(submittedQuote), + roomTotal: '240.00', + taxTotal: '20.00', + grandTotal: '260.00', + lineItems: [ + { date: '2026-10-01', rate: '120.00', tax: '10.00' }, + { date: '2026-10-02', rate: '120.00', tax: '10.00' }, + ], +}; + +type RequestRow = { + id: string; + propertyId: string; + status: 'pending' | 'accepted' | 'denied'; + arrivalDate: string; + departureDate: string; + roomTypeId: string; + ratePlanId: string; + adults: number; + children: number; + guestFirstName: string; + guestLastName: string; + guestEmail: string; + guestPhone: string | null; + specialRequests: string | null; + serviceIds: string[]; + submittedQuoteSnapshot: typeof submittedQuote; + submittedTotal: string; + currentQuoteSnapshot: typeof currentQuote | null; + currencyCode: string; + acceptedPriceSource: 'submitted' | 'current' | 'custom' | null; + acceptedTotal: string | null; + customPriceReason: string | null; + acceptedReservationId: string | null; + acceptedFolioId: string | null; + decidedBy: string | null; + decidedAt: Date | null; + denialReason: string | null; + stripePaymentMethodId: string | null; + createdAt: Date; + updatedAt: Date; +}; + +function pendingRequest(overrides: Partial = {}): RequestRow { + return { + id: REQUEST_ID, + propertyId: PROPERTY_ID, + status: 'pending', + arrivalDate: '2026-10-01', + departureDate: '2026-10-03', + roomTypeId: ROOM_TYPE_ID, + ratePlanId: RATE_PLAN_ID, + adults: 2, + children: 1, + guestFirstName: 'Ada', + guestLastName: 'Lovelace', + guestEmail: 'ada@example.com', + guestPhone: '+34 600 000 000', + specialRequests: 'A quiet room, please.', + serviceIds: [], + submittedQuoteSnapshot: structuredClone(submittedQuote), + submittedTotal: '220.00', + currentQuoteSnapshot: null, + currencyCode: 'EUR', + acceptedPriceSource: null, + acceptedTotal: null, + customPriceReason: null, + acceptedReservationId: null, + acceptedFolioId: null, + decidedBy: null, + decidedAt: null, + denialReason: null, + stripePaymentMethodId: null, + createdAt: new Date('2026-08-24T10:00:00.000Z'), + updatedAt: new Date('2026-08-24T10:00:00.000Z'), + ...overrides, + }; +} + +function previewToken( + quote: { currencyCode: string; grandTotal: string } = currentQuote, + request: RequestRow = pendingRequest(), +) { + return acceptancePreviewFingerprint({ + requestId: request.id, + propertyId: request.propertyId, + requestUpdatedAt: request.updatedAt, + currencyCode: quote.currencyCode, + currentTotal: quote.grandTotal, + }); +} + +type State = { + requests: RequestRow[]; + guests: Array>; + reservations: Array>; + folios: Array>; + payments: Array>; + installments: Array>; + allocations: Array>; + resolutions: Array>; + emailDeliveries: Array>; + audits: Array>; + consequences: Array>; +}; + +function cloneState(state: State): State { + return structuredClone(state); +} + +function restoreState(state: State, snapshot: State): void { + for (const key of Object.keys(snapshot) as Array) { + state[key].splice(0, state[key].length, ...structuredClone(snapshot[key])); + } +} + +function makeDatabase(state: State) { + let rowLockQueue = Promise.resolve(); + let transactionActive = false; + + const rowsFor = (table: unknown): Array> => { + if (table === bookingRequests) return state.requests; + if (table === guests) return state.guests; + if (table === reservations) return state.reservations; + if (table === folios) return state.folios; + if (table === payments) return state.payments; + if (table === bookingRequestInstallments) return state.installments; + if (table === bookingRequestPaymentAllocations) return state.allocations; + if (table === bookingRequestPaymentResolutions) return state.resolutions; + if (table === bookingRequestEmailDeliveries) return state.emailDeliveries; + if (table === auditLogs) return state.audits; + if (table === bookingRequestConsequences) return state.consequences; + return []; + }; + + const createSelect = ( + selection?: Record, + acquireLock?: () => Promise, + ) => { + let table: unknown; + let offset = 0; + let limit: number | undefined; + let orderExpressions: unknown[] = []; + const resolveRows = () => { + let rows = structuredClone(rowsFor(table)); + if (selection && Object.keys(selection).length === 1 && 'count' in selection) { + return [{ count: rows.length }]; + } + if (table === bookingRequests && orderExpressions.length > 0) { + const parts = sqlExpressionParts(orderExpressions[0]); + if (parts.columns.includes('submitted_total')) { + const multiplier = parts.strings.join(' ').toLowerCase().includes('desc') ? -1 : 1; + rows.sort((left, right) => { + const amountOrder = Number(left['submittedTotal']) - Number(right['submittedTotal']); + if (amountOrder !== 0) return amountOrder * multiplier; + return String(left['id']).localeCompare(String(right['id'])) * multiplier; + }); + } + } + if (table === auditLogs) { + rows = rows.map((row) => ({ + ...row, + timelineSequence: row['timelineSequence'] ?? ( + BigInt((row['occurredAt'] as Date).getTime()) * 1_000_000n + + BigInt(String(row['id']).replace(/\D/g, '').slice(-6) || '0') + ), + })); + } + if (table === auditLogs && selection && 'occurredAtMicros' in selection) { + rows = rows.map((row) => ({ + ...row, + occurredAtMicros: row['occurredAtMicros'] + ?? String(BigInt((row['occurredAt'] as Date).getTime()) * 1000n), + })); + } + return rows.slice(offset, limit == null ? undefined : offset + limit); + }; + const chain: Record & PromiseLike = { + from: vi.fn((selectedTable: unknown) => { + table = selectedTable; + return chain; + }), + leftJoin: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), + where: vi.fn(() => chain), + for: vi.fn(async () => { + await acquireLock?.(); + return resolveRows(); + }), + orderBy: vi.fn((...expressions: unknown[]) => { + orderExpressions = expressions; + return chain; + }), + limit: vi.fn((value: number) => { + limit = value; + return chain; + }), + offset: vi.fn((value: number) => { + offset = value; + return chain; + }), + then: (resolve, reject) => Promise.resolve(resolveRows()).then(resolve, reject), + }; + return chain; + }; + + const db: Record = {}; + db['select'] = vi.fn((selection?: Record) => + createSelect(selection)); + + db['insert'] = vi.fn((table: unknown) => ({ + values: vi.fn((values: Record) => { + const insert = () => { + const row = { + id: values['id'] ?? `row-${rowsFor(table).length + 1}`, + ...structuredClone(values), + }; + rowsFor(table).push(row); + return row; + }; + const direct = Promise.resolve().then(() => insert()).then(() => undefined); + return Object.assign(direct, { + returning: vi.fn(async () => [insert()]), + onConflictDoNothing: vi.fn(() => ({ + returning: vi.fn(async () => [insert()]), + })), + }); + }), + })); + + db['update'] = vi.fn((table: unknown) => ({ + set: vi.fn((changes: Record) => { + const apply = () => { + const rows = rowsFor(table); + for (const row of rows) Object.assign(row, structuredClone(changes)); + return structuredClone(rows); + }; + return { + where: vi.fn(() => { + const direct = Promise.resolve().then(() => apply()).then(() => undefined); + return Object.assign(direct, { + returning: vi.fn(async () => apply()), + }); + }), + }; + }), + })); + + db['delete'] = vi.fn(() => { + throw new Error('Booking Request decisions must not delete business records'); + }); + + db['transaction'] = vi.fn(async (callback: (tx: unknown) => Promise) => { + let release = () => undefined; + let acquired = false; + const tx = { + ...db, + select: vi.fn((selection?: Record) => createSelect( + selection, + async () => { + const previous = rowLockQueue; + rowLockQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + acquired = true; + }, + )), + }; + const snapshot = cloneState(state); + transactionActive = true; + try { + return await callback(tx); + } catch (error) { + restoreState(state, snapshot); + throw error; + } finally { + transactionActive = false; + if (acquired) release(); + } + }); + + return { + db, + isTransactionActive: () => transactionActive, + }; +} + +function sqlExpressionParts(value: any, parts = { + columns: [] as string[], + strings: [] as string[], +}) { + if (!value || typeof value !== 'object') return parts; + if (typeof value.name === 'string') parts.columns.push(value.name); + if (Array.isArray(value.value)) { + parts.strings.push(...value.value.filter((part: unknown): part is string => + typeof part === 'string')); + } + if (Array.isArray(value.queryChunks)) { + for (const chunk of value.queryChunks) sqlExpressionParts(chunk, parts); + } + return parts; +} + +function makeHarness(requests: RequestRow[] = [pendingRequest()]) { + const state: State = { + requests: structuredClone(requests), + guests: [], + reservations: [], + folios: [], + payments: [], + installments: [], + allocations: [], + resolutions: [], + emailDeliveries: [], + audits: [], + consequences: [], + }; + const database = makeDatabase(state); + let reservationCreates = 0; + let hasAvailability = true; + const dispatchTransactionStates: boolean[] = []; + const quoteTransactionStates: boolean[] = []; + + const config = { getPublicConfig: vi.fn() }; + const bookingEngine = { + quote: vi.fn(async () => { + quoteTransactionStates.push(database.isTransactionActive()); + return structuredClone(currentQuote); + }), + }; + const availability = { searchAvailability: vi.fn() }; + const ratePlan = { assertSellable: vi.fn() }; + const savedPaymentMethod = { + createSetup: vi.fn(), + resolveSetup: vi.fn(), + charge: vi.fn(() => { + throw new Error('Acceptance must never charge a payment method'); + }), + }; + const webhook = { + dispatchPersisted: vi.fn(async () => { + dispatchTransactionStates.push(database.isTransactionActive()); + }), + emit: vi.fn(async () => { + dispatchTransactionStates.push(database.isTransactionActive()); + }), + }; + const guest = { + create: vi.fn(async (dto: Record) => { + const row = { id: GUEST_ID, ...structuredClone(dto) }; + state.guests.push(row); + return row; + }), + }; + const reservation = { + lockInventory: vi.fn(async () => undefined), + create: vi.fn(async (dto: Record) => { + reservationCreates += 1; + if (!hasAvailability) { + throw new BadRequestException('No availability for requested stay'); + } + const row = { + id: RESERVATION_ID, + bookingId: '33333333-0000-4000-a000-000000000001', + status: 'pending', + ...structuredClone(dto), + }; + state.reservations.push(row); + return row; + }), + }; + const folio = { + createAutoFolio: vi.fn(async (input: Record) => { + const row = { + id: FOLIO_ID, + propertyId: input['propertyId'], + reservationId: input['id'], + guestId: input['guestId'], + currencyCode: input['currencyCode'], + }; + state.folios.push(row); + return row; + }), + recalculateBalance: vi.fn(async () => undefined), + }; + const ancillary = { + attachToReservation: vi.fn(async () => ({ + id: '44444444-0000-4000-a000-000000000001', + })), + ensurePackageComponents: vi.fn(async () => []), + }; + const emailQueueTransactionStates: boolean[] = []; + const emailDeliveryTransactionStates: boolean[] = []; + const mailer = { + queue: vi.fn(async () => { + emailQueueTransactionStates.push(database.isTransactionActive()); + return 'email-delivery-1'; + }), + deliverForRequestBestEffort: vi.fn(async () => { + emailDeliveryTransactionStates.push(database.isTransactionActive()); + }), + }; + + const service = new (BookingRequestService as any)( + database.db, + config, + bookingEngine, + availability, + ratePlan, + savedPaymentMethod, + webhook, + guest, + reservation, + folio, + ancillary, + mailer, + ) as BookingRequestService & Record Promise>; + + return { + service, + state, + database, + bookingEngine, + savedPaymentMethod, + webhook, + guest, + reservation, + folio, + ancillary, + mailer, + emailQueueTransactionStates, + emailDeliveryTransactionStates, + setAvailability(value: boolean) { + hasAvailability = value; + }, + get reservationCreates() { + return reservationCreates; + }, + dispatchTransactionStates, + quoteTransactionStates, + }; +} + +async function call( + service: BookingRequestService & Record Promise>, + method: 'list' | 'findById' | 'auditHistory' | 'acceptancePreview' | 'accept' | 'deny', + args: unknown[], +): Promise { + const fn = service[method]; + if (typeof fn !== 'function') return undefined; + return fn.apply(service, args); +} + +const actor = { + userId: '55555555-0000-4000-a000-000000000001', + userEmail: 'agent@example.com', + ipAddress: '203.0.113.10', +}; + +describe('Booking Request staff HTTP contract', () => { + it('registers concrete DTO validation and read/write permissions', async () => { + const controllerModule = await import('./booking-request.controller').catch(() => null); + const listDtoModule = await import('./dto/list-booking-requests.dto').catch(() => null); + const acceptDtoModule = await import('./dto/accept-booking-request.dto').catch(() => null); + const denyDtoModule = await import('./dto/deny-booking-request.dto').catch(() => null); + + expect(controllerModule).not.toBeNull(); + expect(listDtoModule).not.toBeNull(); + expect(acceptDtoModule).not.toBeNull(); + expect(denyDtoModule).not.toBeNull(); + if (!controllerModule || !listDtoModule || !acceptDtoModule || !denyDtoModule) return; + + const Controller = controllerModule.BookingRequestController; + const reflector = new Reflector(); + expect(reflector.get(PERMISSIONS_KEY, Controller.prototype.list)).toEqual([ + 'reservations.read', + ]); + expect(reflector.get(PERMISSIONS_KEY, Controller.prototype.findById)).toEqual([ + 'reservations.read', + ]); + expect(reflector.get(PERMISSIONS_KEY, Controller.prototype.acceptancePreview)).toEqual([ + 'reservations.read', + ]); + expect(reflector.get(PERMISSIONS_KEY, Controller.prototype.auditHistory)).toEqual([ + 'reservations.read', + ]); + expect(reflector.get(PERMISSIONS_KEY, Controller.prototype.accept)).toEqual([ + 'reservations.write', + ]); + expect(reflector.get(PERMISSIONS_KEY, Controller.prototype.deny)).toEqual([ + 'reservations.write', + ]); + + expect(Reflect.getMetadata( + 'design:paramtypes', + Controller.prototype, + 'accept', + )).toContain(acceptDtoModule.AcceptBookingRequestDto); + expect(Reflect.getMetadata( + 'design:paramtypes', + Controller.prototype, + 'deny', + )).toContain(denyDtoModule.DenyBookingRequestDto); + }); + + it('requires property scope and validates custom pricing/denial input', async () => { + const listDtoModule = await import('./dto/list-booking-requests.dto').catch(() => null); + const acceptDtoModule = await import('./dto/accept-booking-request.dto').catch(() => null); + const denyDtoModule = await import('./dto/deny-booking-request.dto').catch(() => null); + expect(listDtoModule && acceptDtoModule && denyDtoModule).toBeTruthy(); + if (!listDtoModule || !acceptDtoModule || !denyDtoModule) return; + + const missingScope = await validate(plainToInstance( + listDtoModule.ListBookingRequestsDto, + {}, + )); + const invalidSource = await validate(plainToInstance( + acceptDtoModule.AcceptBookingRequestDto, + { priceSource: 'charged' }, + )); + const missingPreview = await validate(plainToInstance( + acceptDtoModule.AcceptBookingRequestDto, + { priceSource: 'current' }, + )); + const blankDenial = await validate(plainToInstance( + denyDtoModule.DenyBookingRequestDto, + { reason: '' }, + )); + const invalidSort = await validate(plainToInstance( + listDtoModule.ListBookingRequestsDto, + { propertyId: PROPERTY_ID, sortBy: 'privateField', sortOrder: 'sideways' }, + )); + expect(missingScope.some((error) => error.property === 'propertyId')).toBe(true); + expect(invalidSource.some((error) => error.property === 'priceSource')).toBe(true); + expect(missingPreview.some((error) => error.property === 'previewToken')).toBe(true); + expect(blankDenial.some((error) => error.property === 'reason')).toBe(true); + expect(invalidSort.map((error) => error.property)).toEqual(expect.arrayContaining([ + 'sortBy', + 'sortOrder', + ])); + }); +}); + +describe('BookingRequestService staff reads', () => { + it('sorts a requested-total page from the durable submitted amount before pagination', async () => { + const harness = makeHarness(Array.from({ length: 25 }, (_, index) => pendingRequest({ + id: `bbbbbbbb-0000-4000-a000-${String(index + 1).padStart(12, '0')}`, + submittedTotal: String(index === 0 ? 9 : index * 10), + submittedQuoteSnapshot: { grandTotal: '999999.00' }, + }))); + + const result = await call(harness.service, 'list', [{ + propertyId: PROPERTY_ID, + page: 2, + limit: 20, + sortBy: 'requestedTotal', + sortOrder: 'desc', + }]); + + expect(result.data.map((row: RequestRow) => row.id)).toEqual([ + 'bbbbbbbb-0000-4000-a000-000000000005', + 'bbbbbbbb-0000-4000-a000-000000000004', + 'bbbbbbbb-0000-4000-a000-000000000003', + 'bbbbbbbb-0000-4000-a000-000000000002', + 'bbbbbbbb-0000-4000-a000-000000000001', + ]); + expect(result.total).toBe(25); + }); + + it('lists only the requested property and never leaks cross-property rows', async () => { + const harness = makeHarness([ + pendingRequest(), + pendingRequest({ id: 'bbbbbbbb-0000-4000-a000-000000000002', propertyId: OTHER_PROPERTY_ID }), + ]); + const result = await call(harness.service, 'list', [{ + propertyId: PROPERTY_ID, + page: 1, + limit: 20, + }]); + + expect(result?.data?.map((row: RequestRow) => row.id)).toEqual([REQUEST_ID]); + }); + + it('serializes list and detail through explicit safe shapes', async () => { + const request = pendingRequest(); + Object.assign(request, { + submissionIdempotencyKey: 'do-not-leak-key', + submissionFingerprint: 'do-not-leak-fingerprint', + setupIntentId: 'seti_secret', + stripeCustomerId: 'cus_secret', + stripePaymentMethodId: 'pm_secret', + cardLastFour: '4242', + cardBrand: 'visa', + consentText: 'internal consent wording', + consentVersion: 'v-secret', + consentedAt: new Date(), + formSnapshot: [{ id: 'question-1', label: 'Internal prompt' }], + applicationAnswers: { 'question-1': 'Approved admin answer' }, + }); + const harness = makeHarness([request]); + + const list = await call(harness.service, 'list', [{ + propertyId: PROPERTY_ID, + page: 1, + limit: 20, + }]); + const detail = await call(harness.service, 'findById', [REQUEST_ID, PROPERTY_ID]); + const forbiddenKeys = [ + 'submissionIdempotencyKey', + 'submissionFingerprint', + 'setupIntentId', + 'stripeCustomerId', + 'stripePaymentMethodId', + 'consentText', + 'consentVersion', + 'consentedAt', + ]; + + expect(list.data[0]).toEqual(expect.objectContaining({ + id: REQUEST_ID, + hasCard: true, + submittedTotal: '220.00', + currencyCode: 'EUR', + })); + expect(Object.keys(list.data[0]).sort()).toEqual([ + 'acceptedPriceSource', 'acceptedReservationId', 'acceptedTotal', 'adults', + 'arrivalDate', 'children', 'createdAt', 'departureDate', 'guestEmail', + 'guestFirstName', 'guestLastName', 'hasCard', 'id', 'propertyId', + 'ratePlanId', 'roomTypeId', 'status', 'submittedTotal', 'currencyCode', + 'updatedAt', + ].sort()); + expect(detail.card).toEqual({ brand: 'visa', lastFour: '4242' }); + expect(detail.applicationAnswers).toEqual({ + 'question-1': 'Approved admin answer', + }); + expect(Object.keys(detail)).toEqual(expect.arrayContaining([ + 'submittedQuoteSnapshot', + 'currentQuoteSnapshot', + 'formSnapshot', + 'applicationAnswers', + ])); + for (const key of forbiddenKeys) { + expect(JSON.stringify(list)).not.toContain(key); + expect(JSON.stringify(detail)).not.toContain(key); + expect(detail).not.toHaveProperty(key); + } + }); + + it('returns not found for a request id that exists under another property', async () => { + const harness = makeHarness([ + pendingRequest({ propertyId: OTHER_PROPERTY_ID }), + ]); + + await expect(call( + harness.service, + 'findById', + [REQUEST_ID, PROPERTY_ID], + )).rejects.toBeInstanceOf(NotFoundException); + }); + + it('returns a bounded stable page of request-owned audit rows', async () => { + const harness = makeHarness(); + harness.state.audits.push( + ...[1, 2, 3].map((sequence) => ({ + id: `10000000-0000-4000-a000-00000000000${sequence}`, + propertyId: PROPERTY_ID, + action: 'update', + entityType: 'booking_request', + entityId: REQUEST_ID, + newValue: { status: sequence === 1 ? 'accepted' : 'pending' }, + occurredAt: new Date(`2026-08-25T10:0${sequence}:00.000Z`), + })), + { + id: '10000000-0000-4000-a000-000000000099', + propertyId: PROPERTY_ID, + action: 'update', + entityType: 'booking_request', + entityId: 'bbbbbbbb-0000-4000-a000-000000000099', + newValue: { status: 'accepted' }, + occurredAt: new Date('2026-08-25T10:09:00.000Z'), + }, + ); + + const result = await call(harness.service, 'auditHistory', [ + REQUEST_ID, + PROPERTY_ID, + { limit: 2 }, + ]); + + expect(result.data).toHaveLength(2); + expect(result.data.every((row: { id: string }) => row.id !== '10000000-0000-4000-a000-000000000099')) + .toBe(true); + expect(result.nextCursor).toEqual(expect.any(String)); + const selectCalls = (harness.database.db['select'] as ReturnType).mock.results; + const auditQuery = selectCalls.at(-1)?.value as { + limit: ReturnType; + }; + expect(auditQuery.limit).toHaveBeenCalledWith(3); + }); + + it('uses an opaque keyset cursor so a newer append cannot duplicate or skip older rows', async () => { + const harness = makeHarness(); + harness.state.audits.push(...[1, 2, 3].map((sequence) => ({ + id: `10000000-0000-4000-a000-00000000003${sequence}`, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + action: 'update', + entityType: 'booking_request', + entityId: REQUEST_ID, + newValue: { status: 'pending' }, + occurredAt: new Date(`2026-08-25T10:0${sequence}:00.000Z`), + }))); + + const first = await call(harness.service, 'auditHistory', [ + REQUEST_ID, + PROPERTY_ID, + { limit: 2 }, + ]); + harness.state.audits.push({ + id: '10000000-0000-4000-a000-000000000034', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + action: 'update', + entityType: 'booking_request', + entityId: REQUEST_ID, + newValue: { status: 'accepted' }, + occurredAt: new Date('2026-08-25T10:04:00.000Z'), + }); + const second = await call(harness.service, 'auditHistory', [ + REQUEST_ID, + PROPERTY_ID, + { limit: 2, cursor: first.nextCursor }, + ]); + + expect(first.data.map((row: { id: string }) => row.id)).toEqual([ + '10000000-0000-4000-a000-000000000033', + '10000000-0000-4000-a000-000000000032', + ]); + expect(second.data.map((row: { id: string }) => row.id)).toEqual([ + '10000000-0000-4000-a000-000000000031', + ]); + expect(new Set([...first.data, ...second.data].map((row: { id: string }) => row.id)).size) + .toBe(3); + expect(second.nextCursor).toBeNull(); + }); + + it('uses the durable timeline sequence in the opaque audit cursor and total ordering', async () => { + const harness = makeHarness(); + harness.state.audits.push( + { + id: '10000000-0000-4000-a000-000000000041', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + action: 'update', + entityType: 'booking_request', + entityId: REQUEST_ID, + newValue: { status: 'accepted' }, + occurredAt: new Date('2026-08-25T10:00:00.000Z'), + timelineSequence: 900n, + }, + { + id: '10000000-0000-4000-a000-000000000043', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + action: 'update', + entityType: 'booking_request', + entityId: REQUEST_ID, + newValue: { status: 'pending' }, + occurredAt: new Date('2026-08-25T10:00:00.000Z'), + timelineSequence: 800n, + }, + { + id: '10000000-0000-4000-a000-000000000042', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + action: 'update', + entityType: 'booking_request', + entityId: REQUEST_ID, + newValue: { status: 'pending' }, + occurredAt: new Date('2026-08-25T10:00:00.000Z'), + timelineSequence: 700n, + }, + ); + + const first = await call(harness.service, 'auditHistory', [ + REQUEST_ID, + PROPERTY_ID, + { limit: 2 }, + ]); + const decodedCursor = JSON.parse( + Buffer.from(first.nextCursor, 'base64url').toString('utf8'), + ); + const second = await call(harness.service, 'auditHistory', [ + REQUEST_ID, + PROPERTY_ID, + { limit: 2, cursor: first.nextCursor }, + ]); + + expect(first.data.map((row: { id: string }) => row.id)).toEqual([ + '10000000-0000-4000-a000-000000000041', + '10000000-0000-4000-a000-000000000043', + ]); + expect(decodedCursor).toMatchObject({ + timelineSequence: '800', + }); + expect(Object.keys(decodedCursor)).toEqual(['timelineSequence']); + expect(decodedCursor).not.toHaveProperty('occurredAt'); + expect(second.data.map((row: { id: string }) => row.id)).toEqual([ + '10000000-0000-4000-a000-000000000042', + ]); + }); + + it('rejects a malformed audit cursor before querying audit rows', async () => { + const harness = makeHarness(); + + await expect(call(harness.service, 'auditHistory', [ + REQUEST_ID, + PROPERTY_ID, + { limit: 25, cursor: 'not-an-audit-cursor' }, + ])).rejects.toThrow(/invalid booking request audit cursor/i); + }); + + it('includes request-owned audit rows even when a legacy payload omits the request id', async () => { + const harness = makeHarness(); + harness.state.payments.push({ + id: PAYMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + }); + harness.state.audits.push({ + id: '10000000-0000-4000-a000-000000000010', + propertyId: PROPERTY_ID, + action: 'update', + entityType: 'payment', + entityId: PAYMENT_ID, + newValue: { status: 'settled', amount: '80.00', currencyCode: 'EUR' }, + description: 'Legacy payment settled', + occurredAt: new Date('2026-08-25T10:10:00.000Z'), + }); + + const page = await call(harness.service, 'auditHistory', [REQUEST_ID, PROPERTY_ID]); + + expect(page.data).toEqual([ + expect.objectContaining({ + id: '10000000-0000-4000-a000-000000000010', + summary: 'payment.updated', + }), + ]); + }); + + it('keeps directly related installment and allocation tombstones after child deletion', async () => { + const harness = makeHarness(); + harness.state.audits.push({ + id: '10000000-0000-4000-a000-000000000021', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + action: 'delete', + entityType: 'booking_request_installment', + entityId: '20000000-0000-4000-a000-000000000021', + previousValue: { label: 'Deleted deposit', fixedAmount: '40.00' }, + description: 'Booking request installment deleted', + occurredAt: new Date('2026-08-25T10:21:00.000Z'), + }, { + id: '10000000-0000-4000-a000-000000000022', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + action: 'delete', + entityType: 'booking_request_payment_allocation', + entityId: '20000000-0000-4000-a000-000000000022', + previousValue: { amount: '40.00' }, + description: 'Booking request allocation removed', + occurredAt: new Date('2026-08-25T10:22:00.000Z'), + }); + + const page = await call(harness.service, 'auditHistory', [REQUEST_ID, PROPERTY_ID]); + + expect(page.data.map((row: { id: string }) => row.id)).toEqual([ + '10000000-0000-4000-a000-000000000022', + '10000000-0000-4000-a000-000000000021', + ]); + expect(JSON.stringify(page.data)).not.toContain('bookingRequestId'); + }); + + it('returns immutable related audit rows through an explicit sanitized DTO', async () => { + const harness = makeHarness(); + harness.state.installments.push({ + id: '20000000-0000-4000-a000-000000000001', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + }); + harness.state.payments.push({ + id: PAYMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + }); + harness.state.emailDeliveries.push({ + id: '30000000-0000-4000-a000-000000000001', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + }); + harness.state.audits.push( + { + id: '10000000-0000-4000-a000-000000000001', + propertyId: PROPERTY_ID, + action: 'update', + entityType: 'booking_request', + entityId: REQUEST_ID, + userId: actor.userId, + userEmail: actor.userEmail, + previousValue: { status: 'pending', consentText: 'secret consent' }, + newValue: { + status: 'accepted', + acceptedTotal: '240.00', + priceSource: 'custom', + processorToken: 'tok_secret', + }, + description: 'Booking request accepted', + occurredAt: new Date('2026-08-25T10:00:00.000Z'), + }, + { + id: '10000000-0000-4000-a000-000000000002', + propertyId: PROPERTY_ID, + action: 'create', + entityType: 'booking_request_installment', + entityId: '20000000-0000-4000-a000-000000000001', + userEmail: null, + newValue: { + requestId: REQUEST_ID, + label: 'Deposit', + fixedAmount: '80.00', + applicationAnswers: { passport: 'secret' }, + }, + description: 'Booking request installment created', + occurredAt: new Date('2026-08-25T10:01:00.000Z'), + }, + { + id: '10000000-0000-4000-a000-000000000003', + propertyId: PROPERTY_ID, + action: 'create', + entityType: 'payment', + entityId: PAYMENT_ID, + userEmail: actor.userEmail, + newValue: { + requestId: REQUEST_ID, + amount: '80.00', + currencyCode: 'EUR', + status: 'captured', + gatewayTransactionId: 'pi_secret', + }, + description: 'Booking request saved-card charge captured', + occurredAt: new Date('2026-08-25T10:02:00.000Z'), + }, + { + id: '10000000-0000-4000-a000-000000000004', + propertyId: PROPERTY_ID, + action: 'create', + entityType: 'booking_request_email_delivery', + entityId: '30000000-0000-4000-a000-000000000001', + newValue: { bookingRequestId: REQUEST_ID, kind: 'accepted', status: 'pending' }, + description: 'Booking request accepted email queued', + occurredAt: new Date('2026-08-25T10:03:00.000Z'), + }, + { + id: '10000000-0000-4000-a000-000000000005', + propertyId: PROPERTY_ID, + action: 'update', + entityType: 'booking_request_email_delivery', + entityId: '30000000-0000-4000-a000-000000000001', + newValue: { status: 'sent', attempts: 2, providerMessageId: 'msg_secret' }, + description: 'Booking request email delivered', + occurredAt: new Date('2026-08-25T10:04:00.000Z'), + }, + { + id: '10000000-0000-4000-a000-000000000006', + propertyId: OTHER_PROPERTY_ID, + action: 'create', + entityType: 'payment', + entityId: PAYMENT_ID, + newValue: { requestId: REQUEST_ID, processorToken: 'cross-property-secret' }, + description: 'Unrelated payment', + occurredAt: new Date('2026-08-25T10:05:00.000Z'), + }, + { + id: '10000000-0000-4000-a000-000000000007', + propertyId: PROPERTY_ID, + action: 'update', + entityType: 'booking_request_email_delivery', + entityId: '30000000-0000-4000-a000-000000000001', + newValue: { status: 'provider_secret_state' }, + description: 'Booking request email state changed', + occurredAt: new Date('2026-08-25T10:06:00.000Z'), + }, + ); + + const page = await call(harness.service, 'auditHistory', [REQUEST_ID, PROPERTY_ID]); + const result = page.data; + + expect(result).toHaveLength(6); + expect(page.nextCursor).toBeNull(); + expect(result.find((entry: { summary: string }) => entry.summary === 'request.accepted')).toMatchObject({ + action: 'update', + actorDisplay: actor.userEmail, + summary: 'request.accepted', + details: { status: 'accepted', acceptedTotal: '240.00', priceSource: 'custom' }, + }); + expect(result.map((entry: { summary: string }) => entry.summary)).toEqual(expect.arrayContaining([ + 'installment.created', + 'payment.captured', + 'email.pending', + 'email.sent', + 'email.updated', + ])); + const serialized = JSON.stringify(result); + for (const forbidden of [ + 'processorToken', 'tok_secret', 'gatewayTransactionId', 'pi_secret', + 'providerMessageId', 'msg_secret', 'consentText', 'applicationAnswers', + 'cross-property-secret', actor.userId, + ]) expect(serialized).not.toContain(forbidden); + }); +}); + +describe('BookingRequestService acceptance', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('previews only submitted/current totals and an opaque property-scoped fingerprint', async () => { + const harness = makeHarness(); + + const preview = await call(harness.service, 'acceptancePreview', [ + REQUEST_ID, + PROPERTY_ID, + ]); + + expect(preview).toEqual({ + requestId: REQUEST_ID, + submittedTotal: '220.00', + currentTotal: '260.00', + currencyCode: 'EUR', + previewVersion: 1, + previewToken: previewToken(), + }); + expect(JSON.stringify(preview)).not.toContain('lineItems'); + expect(harness.quoteTransactionStates).toEqual([false]); + }); + + it.each([ + ['submitted', undefined, undefined, '220.00'], + ['current', undefined, undefined, '260.00'], + ['custom', '240.00', 'Goodwill rate', '240.00'], + ] as const)( + 'accepts the %s price without charging and records the decision actor', + async (priceSource, customTotal, customReason, expectedTotal) => { + const harness = makeHarness(); + const result = await call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource, customTotal, customReason, previewToken: previewToken() }, + actor, + ]); + + expect(result).toEqual({ + requestId: REQUEST_ID, + status: 'accepted', + reservationId: RESERVATION_ID, + folioId: FOLIO_ID, + totalAmount: expectedTotal, + currencyCode: 'EUR', + priceSource, + customReason: customReason ?? null, + }); + expect(harness.state.requests[0]).toMatchObject({ + status: 'accepted', + acceptedPriceSource: priceSource, + acceptedTotal: expectedTotal, + customPriceReason: customReason ?? null, + acceptedReservationId: RESERVATION_ID, + acceptedFolioId: FOLIO_ID, + decidedBy: actor.userId, + currentQuoteSnapshot: currentQuote, + }); + expect(harness.savedPaymentMethod.charge).not.toHaveBeenCalled(); + expect(harness.reservation.create.mock.calls[0]?.[1]).toMatchObject({ + acceptedPricingSnapshot: expect.objectContaining({ + source: priceSource, + currencyCode: 'EUR', + grandTotal: expectedTotal, + }), + }); + expect(harness.state.audits).toContainEqual(expect.objectContaining({ + userId: actor.userId, + userEmail: actor.userEmail, + ipAddress: actor.ipAddress, + })); + expect(harness.state.audits.map((entry) => entry['description'])).toEqual( + expect.arrayContaining([ + 'Webhook event: booking_request.accepted', + 'Webhook event: reservation.created', + 'Webhook event: folio.created', + ]), + ); + expect(harness.state.consequences).toContainEqual(expect.objectContaining({ + kind: 'accepted_event', + payload: expect.objectContaining({ + event: 'booking_request.accepted', + data: expect.objectContaining({ currencyCode: 'EUR' }), + }), + })); + expect(harness.quoteTransactionStates).toEqual([true]); + expect(harness.dispatchTransactionStates.every((active) => !active)).toBe(true); + }, + ); + + it('rejects custom pricing without a reason before creating business records', async () => { + const harness = makeHarness(); + + await expect(call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'custom', customTotal: '240.00', previewToken: previewToken() }, + actor, + ])).rejects.toThrow(/reason/i); + expect(harness.state.requests[0]?.status).toBe('pending'); + expect(harness.state.reservations).toHaveLength(0); + expect(harness.state.guests).toHaveLength(0); + }); + + it('rejects custom totals with excess currency precision instead of rounding them', async () => { + const harness = makeHarness(); + + await expect(call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { + priceSource: 'custom', + customTotal: '240.001', + customReason: 'Must remain exact', + previewToken: previewToken(), + }, + actor, + ])).rejects.toThrow(/minor units|precision/i); + expect(harness.state.requests[0]?.status).toBe('pending'); + expect(harness.state.reservations).toHaveLength(0); + }); + + it('persists, audits, and returns an equal-total custom reason independently of adjustment', async () => { + const harness = makeHarness(); + + const result = await call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { + priceSource: 'custom', + customTotal: '260.00', + customReason: 'Matched a written offer', + previewToken: previewToken(), + }, + actor, + ]); + + expect(result).toMatchObject({ + requestId: REQUEST_ID, + status: 'accepted', + priceSource: 'custom', + customReason: 'Matched a written offer', + totalAmount: '260.00', + }); + expect(harness.state.requests[0]).toMatchObject({ + acceptedPriceSource: 'custom', + acceptedTotal: '260.00', + customPriceReason: 'Matched a written offer', + }); + expect(harness.reservation.create.mock.calls[0]?.[1]).toMatchObject({ + acceptedPricingSnapshot: expect.objectContaining({ + source: 'custom', + customReason: 'Matched a written offer', + adjustment: null, + }), + }); + expect(harness.state.audits).toContainEqual(expect.objectContaining({ + description: 'Booking request accepted', + newValue: expect.objectContaining({ + priceSource: 'custom', + customPriceReason: 'Matched a written offer', + }), + })); + }); + + it('leaves the request pending when canonical reservation creation finds no availability', async () => { + const harness = makeHarness(); + harness.setAvailability(false); + + const acceptance = call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'current', previewToken: previewToken() }, + actor, + ]); + await expect(acceptance).rejects.toBeInstanceOf(ConflictException); + await expect(acceptance).rejects.toThrow(/availability/i); + expect(harness.state.requests[0]?.status).toBe('pending'); + expect(harness.state.reservations).toHaveLength(0); + expect(harness.state.folios).toHaveLength(0); + }); + + it('returns a conflict and leaves the request pending when the pre-transaction quote finds no availability', async () => { + const harness = makeHarness(); + harness.bookingEngine.quote.mockRejectedValueOnce( + new BadRequestException('No availability for the requested room type and dates'), + ); + + await expect(call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'submitted', previewToken: previewToken() }, + actor, + ])).rejects.toBeInstanceOf(ConflictException); + expect(harness.state.requests[0]?.status).toBe('pending'); + expect(harness.state.reservations).toHaveLength(0); + }); + + it('rejects a current quote in a different currency without creating records', async () => { + const harness = makeHarness(); + harness.bookingEngine.quote.mockResolvedValueOnce({ + ...structuredClone(currentQuote), + currencyCode: 'USD', + }); + + await expect(call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'current', previewToken: previewToken({ ...currentQuote, currencyCode: 'USD' }) }, + actor, + ])).rejects.toThrow(/currency/i); + expect(harness.state.requests[0]?.status).toBe('pending'); + expect(harness.state.reservations).toHaveLength(0); + }); + + it('rejects a legacy scale-three request before accepting it into ledger-backed records', async () => { + const bhdQuote = { + ...structuredClone(currentQuote), + currencyCode: 'BHD', + grandTotal: '260.000', + }; + const harness = makeHarness([pendingRequest({ + currencyCode: 'BHD', + submittedQuoteSnapshot: { + ...structuredClone(submittedQuote), + currencyCode: 'BHD', + grandTotal: '220.000', + }, + })]); + harness.bookingEngine.quote.mockResolvedValue(bhdQuote); + + const acceptance = call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'submitted', previewToken: previewToken(bhdQuote, harness.state.requests[0]!) }, + actor, + ]); + await expect(acceptance).rejects.toBeInstanceOf(ConflictException); + await expect(acceptance).rejects.toThrow(/BHD.*scale-two payment ledger/i); + expect(harness.state.requests[0]?.status).toBe('pending'); + expect(harness.state.reservations).toHaveLength(0); + expect(harness.state.folios).toHaveLength(0); + }); + + it('rejects acceptance when the authoritative quote changes after preview', async () => { + const harness = makeHarness(); + const preview = await call(harness.service, 'acceptancePreview', [ + REQUEST_ID, + PROPERTY_ID, + ]); + harness.bookingEngine.quote.mockResolvedValueOnce({ + ...structuredClone(currentQuote), + roomTotal: '260.00', + grandTotal: '280.00', + lineItems: [ + { date: '2026-10-01', rate: '130.00', tax: '10.00' }, + { date: '2026-10-02', rate: '130.00', tax: '10.00' }, + ], + }); + + await expect(call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'current', previewToken: preview.previewToken }, + actor, + ])).rejects.toThrow(/preview.*changed/i); + expect(harness.state.requests[0]?.status).toBe('pending'); + expect(harness.state.reservations).toHaveLength(0); + expect(harness.state.guests).toHaveLength(0); + }); + + it('serializes simultaneous acceptance and creates exactly one reservation', async () => { + const harness = makeHarness(); + + const [first, second] = await Promise.all([ + call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'submitted', previewToken: previewToken() }, + actor, + ]), + call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'submitted', previewToken: previewToken() }, + actor, + ]), + ]); + + expect(first).toEqual(second); + expect(first).toEqual({ + requestId: REQUEST_ID, + status: 'accepted', + reservationId: RESERVATION_ID, + folioId: FOLIO_ID, + totalAmount: '220.00', + currencyCode: 'EUR', + priceSource: 'submitted', + customReason: null, + }); + expect(harness.reservationCreates).toBe(1); + expect(harness.state.reservations).toHaveLength(1); + expect(harness.mailer.queue).toHaveBeenCalledWith(expect.objectContaining({ + logicalKey: 'decision:accepted', + kind: 'accepted', + recipient: 'ada@example.com', + }), expect.anything()); + expect(harness.emailQueueTransactionStates).toEqual([true]); + expect(harness.emailDeliveryTransactionStates.every((active) => !active)).toBe(true); + }); + + it('keeps one of two different requests pending when they compete for the last room', async () => { + const otherRequestId = 'bbbbbbbb-0000-4000-a000-000000000002'; + const first = makeHarness([pendingRequest()]); + const second = makeHarness([pendingRequest({ id: otherRequestId })]); + let inventoryAvailable = true; + let inventoryQueue = Promise.resolve(); + + const useSharedInventory = (harness: ReturnType) => { + let releaseInventory: (() => void) | undefined; + const createReservation = harness.reservation.create.getMockImplementation()!; + harness.reservation.lockInventory.mockImplementation(async () => { + const previous = inventoryQueue; + inventoryQueue = new Promise((resolve) => { + releaseInventory = resolve; + }); + await previous; + }); + harness.bookingEngine.quote.mockImplementation(async () => { + if (!inventoryAvailable) { + releaseInventory?.(); + throw new BadRequestException('No availability for requested stay'); + } + return structuredClone(currentQuote); + }); + harness.reservation.create.mockImplementation(async (...args: any[]) => { + const reservation = await createReservation(...args); + inventoryAvailable = false; + releaseInventory?.(); + return reservation; + }); + }; + useSharedInventory(first); + useSharedInventory(second); + + const results = await Promise.allSettled([ + call(first.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'current', previewToken: previewToken() }, + actor, + ]), + call(second.service, 'accept', [ + otherRequestId, + PROPERTY_ID, + { priceSource: 'current', previewToken: previewToken() }, + actor, + ]), + ]); + + expect(results.map((result) => result.status).sort()).toEqual([ + 'fulfilled', + 'rejected', + ]); + expect([ + first.state.requests[0]?.status, + second.state.requests[0]?.status, + ].sort()).toEqual(['accepted', 'pending']); + expect(first.state.reservations.length + second.state.reservations.length).toBe(1); + }); + + it('returns the linked reservation when an accepted request is replayed', async () => { + const accepted = pendingRequest({ + status: 'accepted', + acceptedReservationId: RESERVATION_ID, + acceptedFolioId: FOLIO_ID, + acceptedPriceSource: 'submitted', + acceptedTotal: '220.00', + }); + const harness = makeHarness([accepted]); + harness.state.reservations.push({ + id: RESERVATION_ID, + propertyId: PROPERTY_ID, + totalAmount: '220.00', + }); + + const result = await call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'submitted', previewToken: previewToken() }, + actor, + ]); + + expect(result).toEqual({ + requestId: REQUEST_ID, + status: 'accepted', + reservationId: RESERVATION_ID, + folioId: FOLIO_ID, + totalAmount: '220.00', + currencyCode: 'EUR', + priceSource: 'submitted', + customReason: null, + }); + expect(harness.reservationCreates).toBe(0); + }); + + it('links pre-acceptance payments to the new folio without losing request provenance', async () => { + const harness = makeHarness(); + harness.state.payments.push({ + id: PAYMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + status: 'captured', + amount: '100.00', + }); + + await call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'submitted', previewToken: previewToken() }, + actor, + ]); + + expect(harness.state.payments[0]).toMatchObject({ + id: PAYMENT_ID, + bookingRequestId: REQUEST_ID, + folioId: FOLIO_ID, + }); + }); + + it('persists selected and package ancillary events with the canonical payload contract', async () => { + const serviceId = '99999999-0000-4000-a000-000000000001'; + const packageServiceId = '99999999-0000-4000-a000-000000000002'; + const harness = makeHarness([ + pendingRequest({ serviceIds: [serviceId] }), + ]); + const acceptedQuote = { + ...structuredClone(currentQuote), + services: [{ + serviceId, + code: 'PARK', + name: 'Parking', + postingRule: 'once', + chargeType: 'parking', + currencyCode: 'EUR', + unitPrice: '15.00', + quantity: 1, + lineTotal: '15.00', + taxTotal: '2.00', + lineItems: [{ date: '2026-10-01', amount: '15.00', tax: '2.00' }], + }], + servicesTotal: '15.00', + servicesTaxTotal: '2.00', + grandTotal: '277.00', + }; + harness.bookingEngine.quote.mockResolvedValue(acceptedQuote); + harness.ancillary.attachToReservation.mockResolvedValue({ + id: '44444444-0000-4000-a000-000000000001', + reservationId: RESERVATION_ID, + serviceId, + serviceName: 'Parking', + sourceChannel: 'booking_engine', + quantity: 1, + unitPrice: '15.00', + postingRule: 'once', + }); + harness.ancillary.ensurePackageComponents.mockResolvedValue([{ + id: '44444444-0000-4000-a000-000000000002', + reservationId: RESERVATION_ID, + serviceId: packageServiceId, + serviceName: 'Included transfer', + sourceChannel: 'package', + quantity: 1, + unitPrice: '0.00', + postingRule: 'once', + }]); + + await call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'current', previewToken: previewToken(acceptedQuote) }, + actor, + ]); + + const serviceEvents = harness.state.consequences + .filter((row) => String(row['kind']).startsWith('service:')) + .map((row) => (row['payload'] as any).data); + expect(serviceEvents).toEqual([ + { + reservationId: RESERVATION_ID, + serviceId, + serviceName: 'Parking', + sourceChannel: 'booking_engine', + quantity: 1, + unitPrice: '15.00', + postingRule: 'once', + }, + { + reservationId: RESERVATION_ID, + serviceId: packageServiceId, + serviceName: 'Included transfer', + sourceChannel: 'package', + quantity: 1, + unitPrice: '0.00', + postingRule: 'once', + }, + ]); + }); + + it('treats cross-property acceptance as not found', async () => { + const harness = makeHarness([pendingRequest({ propertyId: OTHER_PROPERTY_ID })]); + + await expect(call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'submitted', previewToken: previewToken() }, + actor, + ])).rejects.toBeInstanceOf(NotFoundException); + expect(harness.state.reservations).toHaveLength(0); + }); +}); + +describe('BookingRequestService denial', () => { + it('blocks denial while captured money remains unresolved and preserves all rows', async () => { + const harness = makeHarness(); + harness.state.payments.push({ + id: PAYMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + originalPaymentId: null, + status: 'captured', + amount: '100.00', + }); + + await expect(call(harness.service, 'deny', [ + REQUEST_ID, + PROPERTY_ID, + { reason: 'Unable to accommodate' }, + actor, + ])).rejects.toThrow(/unresolved money/i); + expect(harness.state.requests).toHaveLength(1); + expect(harness.state.requests[0]?.status).toBe('pending'); + expect(harness.state.payments).toHaveLength(1); + }); + + it('blocks denial while a request payment attempt is pending', async () => { + const harness = makeHarness(); + harness.state.payments.push({ + id: PAYMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + originalPaymentId: null, + status: 'pending', + amount: '100.00', + }); + + await expect(call(harness.service, 'deny', [ + REQUEST_ID, + PROPERTY_ID, + { reason: 'Unable to accommodate' }, + actor, + ])).rejects.toThrow(/pending payment/i); + expect(harness.state.requests[0]?.status).toBe('pending'); + }); + + it('blocks denial while a refund capacity claim is pending', async () => { + const harness = makeHarness(); + harness.state.payments.push({ + id: PAYMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + originalPaymentId: null, + status: 'captured', + amount: '100.00', + }); + harness.state.resolutions.push({ + id: '66666666-0000-4000-a000-000000000002', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + type: 'refund', + status: 'pending', + amount: '100.00', + reason: 'Gateway refund pending', + }); + + await expect(call(harness.service, 'deny', [ + REQUEST_ID, + PROPERTY_ID, + { reason: 'Unable to accommodate' }, + actor, + ])).rejects.toThrow(/pending refund|pending payment resolution/i); + expect(harness.state.requests[0]?.status).toBe('pending'); + }); + + it('treats canonical child returns and retained remainder as resolved without double subtraction', async () => { + const childId = '22222222-0000-4000-a000-000000000099'; + const harness = makeHarness(); + harness.state.payments.push({ + id: PAYMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + originalPaymentId: null, + status: 'captured', + amount: '100.00', + }, { + id: childId, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + originalPaymentId: PAYMENT_ID, + status: 'captured', + amount: '-40.00', + }); + harness.state.resolutions.push({ + id: '66666666-0000-4000-a000-000000000011', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + movementId: childId, + type: 'refund', + status: 'completed', + amount: '40.00', + reason: 'Canonical return provenance', + }, { + id: '66666666-0000-4000-a000-000000000012', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + movementId: null, + type: 'retained', + status: 'completed', + amount: '60.00', + reason: 'Non-refundable supplier cost', + }); + + await expect(call(harness.service, 'deny', [ + REQUEST_ID, + PROPERTY_ID, + { reason: 'Unable to accommodate' }, + actor, + ])).resolves.toMatchObject({ status: 'denied' }); + }); + + it('treats a full generic canonical child return as resolved without a resolution row', async () => { + const harness = makeHarness(); + harness.state.payments.push({ + id: PAYMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + originalPaymentId: null, + status: 'captured', + amount: '100.00', + }, { + id: '22222222-0000-4000-a000-000000000097', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + originalPaymentId: PAYMENT_ID, + status: 'captured', + amount: '-100.00', + }); + + await expect(call(harness.service, 'deny', [ + REQUEST_ID, + PROPERTY_ID, + { reason: 'Unable to accommodate' }, + actor, + ])).resolves.toMatchObject({ status: 'denied' }); + }); + + it('still blocks denial for the unresolved remainder after partial child and retain evidence', async () => { + const harness = makeHarness(); + harness.state.payments.push({ + id: PAYMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + originalPaymentId: null, + status: 'captured', + amount: '100.00', + }, { + id: '22222222-0000-4000-a000-000000000098', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + originalPaymentId: PAYMENT_ID, + status: 'captured', + amount: '-40.00', + }); + harness.state.resolutions.push({ + id: '66666666-0000-4000-a000-000000000013', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + movementId: null, + type: 'retained', + status: 'completed', + amount: '50.00', + reason: 'Partial supplier cost', + }); + + await expect(call(harness.service, 'deny', [ + REQUEST_ID, + PROPERTY_ID, + { reason: 'Unable to accommodate' }, + actor, + ])).rejects.toThrow(/10\.00.*unresolved|unresolved money/i); + }); + + it('denies after money is resolved, records actor, and delivers consequences after commit', async () => { + const harness = makeHarness(); + harness.state.payments.push({ + id: PAYMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + originalPaymentId: null, + status: 'captured', + amount: '100.00', + }); + harness.state.resolutions.push({ + id: '66666666-0000-4000-a000-000000000001', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + type: 'retained', + amount: '100.00', + reason: 'Non-refundable supplier cost', + }); + + const result = await call(harness.service, 'deny', [ + REQUEST_ID, + PROPERTY_ID, + { reason: 'Unable to accommodate' }, + actor, + ]); + + expect(result).toEqual({ + requestId: REQUEST_ID, + status: 'denied', + denialReason: 'Unable to accommodate', + decidedAt: expect.any(Date), + }); + expect(harness.state.requests).toHaveLength(1); + expect(harness.state.payments).toHaveLength(1); + expect(harness.state.resolutions).toHaveLength(1); + expect(harness.state.audits).toContainEqual(expect.objectContaining({ + userId: actor.userId, + userEmail: actor.userEmail, + ipAddress: actor.ipAddress, + })); + expect(harness.dispatchTransactionStates.length).toBeGreaterThan(0); + expect(harness.dispatchTransactionStates.every((active) => !active)).toBe(true); + expect(harness.mailer.queue).toHaveBeenCalledWith(expect.objectContaining({ + logicalKey: 'decision:denied', + kind: 'denied', + recipient: 'ada@example.com', + }), expect.anything()); + expect(harness.emailQueueTransactionStates).toEqual([true]); + expect(harness.emailDeliveryTransactionStates).toEqual([false]); + }); + + it('keeps a denied decision committed when post-commit email delivery fails', async () => { + const harness = makeHarness(); + harness.mailer.deliverForRequestBestEffort.mockRejectedValueOnce( + new Error('transport unavailable'), + ); + + await expect(call(harness.service, 'deny', [ + REQUEST_ID, + PROPERTY_ID, + { reason: 'Unable to accommodate' }, + actor, + ])).resolves.toMatchObject({ status: 'denied' }); + expect(harness.state.requests[0]?.status).toBe('denied'); + }); + + it('replays a denied decision and retries its pending consequence idempotently', async () => { + const harness = makeHarness(); + harness.webhook.dispatchPersisted.mockRejectedValueOnce( + new Error('process stopped after commit'), + ); + + const first = await call(harness.service, 'deny', [ + REQUEST_ID, + PROPERTY_ID, + { reason: 'Unable to accommodate' }, + actor, + ]); + expect(harness.state.requests[0]?.status).toBe('denied'); + expect(harness.state.consequences[0]).toMatchObject({ status: 'pending' }); + + const replay = await call(harness.service, 'deny', [ + REQUEST_ID, + PROPERTY_ID, + { reason: 'Different replay text is ignored' }, + actor, + ]); + + expect(replay).toEqual(first); + expect(harness.state.consequences[0]).toMatchObject({ status: 'completed' }); + expect(harness.state.requests).toHaveLength(1); + }); + + it('treats cross-property denial as not found', async () => { + const harness = makeHarness([pendingRequest({ propertyId: OTHER_PROPERTY_ID })]); + + await expect(call(harness.service, 'deny', [ + REQUEST_ID, + PROPERTY_ID, + { reason: 'Unable to accommodate' }, + actor, + ])).rejects.toBeInstanceOf(NotFoundException); + expect(harness.state.requests[0]?.status).toBe('pending'); + }); +}); + +describe('Booking Request durable consequence recovery', () => { + function seedPendingConsequence(harness: ReturnType) { + harness.state.consequences.push({ + id: '77777777-0000-4000-a000-000000000001', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + kind: 'created_event', + payload: { + event: 'booking_request.created', + entityType: 'booking_request', + entityId: REQUEST_ID, + propertyId: PROPERTY_ID, + data: { requestId: REQUEST_ID, status: 'pending' }, + timestamp: '2026-08-24T10:00:00.000Z', + }, + status: 'pending', + attempts: 0, + claimedAt: null, + createdAt: new Date('2026-08-24T10:00:00.000Z'), + updatedAt: new Date('2026-08-24T10:00:00.000Z'), + }); + } + + it('scans and dispatches a consequence left pending by a process crash', async () => { + const harness = makeHarness(); + seedPendingConsequence(harness); + + const scanned = await (harness.service as any).processPendingConsequences(); + + expect(scanned).toBe(1); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledWith( + expect.objectContaining({ event: 'booking_request.created' }), + '77777777-0000-4000-a000-000000000001', + ); + expect(harness.state.consequences[0]).toMatchObject({ status: 'completed' }); + }); + + it('property-scoped claims allow concurrent scanners to dispatch a logical event once', async () => { + const harness = makeHarness(); + seedPendingConsequence(harness); + + await Promise.all([ + (harness.service as any).processPendingConsequences(), + (harness.service as any).processPendingConsequences(), + ]); + + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledTimes(1); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledWith( + expect.anything(), + '77777777-0000-4000-a000-000000000001', + ); + }); + + it('recovers a stale processing lease left by a stopped worker', async () => { + const harness = makeHarness(); + seedPendingConsequence(harness); + Object.assign(harness.state.consequences[0]!, { + status: 'processing', + claimedAt: new Date(Date.now() - 10 * 60 * 1000), + }); + + await (harness.service as any).processPendingConsequences(); + + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledTimes(1); + expect(harness.state.consequences[0]).toMatchObject({ + status: 'completed', + claimedAt: null, + }); + }); +}); + +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-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..cc525979 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-default-flow-regression.spec.ts @@ -0,0 +1,767 @@ +import { execFileSync } from 'node:child_process'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { + auditLogs, + bookingEngineConfig, + bookingRequestConsequences, + bookingRequestEmailDeliveries, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + bookingRequestPaymentResolutions, + bookingRequestStayAmendments, + bookingRequests, + charges, + depositLedgerEntries, + folios, + payments, + properties, + ratePlans, + reservations, + rooms, + roomTypes, + webhookDeliveries, +} from './booking-request-db.js'; +import * as schema from './booking-request-db.js'; +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 './booking-request.service'; + +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 DATABASE_UTILITY_TIMEOUT_MS = 30_000; +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); +} + +function databaseUrlFor(databaseName: string): string { + const url = new URL(connectionTemplate); + url.pathname = `/${databaseName}`; + return url.toString(); +} + +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, + }); +} + +function isMissingExecutable(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + +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 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 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); +} + +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/push-schema.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 { AppModule } = await import('../../app.module'); + 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 ? { bookingMode } : {}), + }); + 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-email.templates.ts b/apps/api/src/modules/booking-request/booking-request-email.templates.ts new file mode 100644 index 00000000..45139bd0 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-email.templates.ts @@ -0,0 +1,118 @@ +export type BookingRequestEmailContent = { + subject: string; + bodyText: string; +}; + +type StayEmailInput = { + guestFirstName: string; + arrivalDate: string; + departureDate: string; +}; + +type MoneyEmailInput = { + guestFirstName: string; + amount: string; + currencyCode: string; +}; + +function greeting(guestFirstName: string): string { + const name = guestFirstName.trim(); + return name ? `Hello ${name},` : 'Hello,'; +} + +function money(amount: string, currencyCode: string): string { + return `${amount} ${currencyCode.trim().toUpperCase()}`; +} + +export function requestReceivedEmail(input: StayEmailInput): BookingRequestEmailContent { + return { + subject: 'We received your booking request', + bodyText: [ + greeting(input.guestFirstName), + '', + `We received your booking request for ${input.arrivalDate} to ${input.departureDate}.`, + 'The property will review it and contact you with a decision.', + 'This message does not confirm a reservation.', + ].join('\n'), + }; +} + +export function acceptedBookingRequestEmail( + input: StayEmailInput & { acceptedTotal: string; currencyCode: string }, +): BookingRequestEmailContent { + return { + subject: 'Your booking request was accepted', + bodyText: [ + greeting(input.guestFirstName), + '', + `Your booking request for ${input.arrivalDate} to ${input.departureDate} was accepted.`, + `Accepted stay total: ${money(input.acceptedTotal, input.currencyCode)}.`, + 'The property will contact you if any further information is needed.', + ].join('\n'), + }; +} + +export function deniedBookingRequestEmail(input: StayEmailInput): BookingRequestEmailContent { + return { + subject: 'Your booking request was not accepted', + bodyText: [ + greeting(input.guestFirstName), + '', + `The property was unable to accept your booking request for ${input.arrivalDate} to ${input.departureDate}.`, + 'No reservation was created from this request.', + ].join('\n'), + }; +} + +export function paymentReceivedBookingRequestEmail( + input: MoneyEmailInput & { source: 'saved_card' | 'external' }, +): BookingRequestEmailContent { + const description = input.source === 'external' + ? 'A payment collected by the property was recorded' + : 'Your payment was received'; + return { + subject: 'Payment received for your booking', + bodyText: [ + greeting(input.guestFirstName), + '', + `${description}: ${money(input.amount, input.currencyCode)}.`, + 'Thank you.', + ].join('\n'), + }; +} + +export function refundedBookingRequestPaymentEmail( + input: MoneyEmailInput & { source: 'refund' | 'external_return' }, +): BookingRequestEmailContent { + const description = input.source === 'external_return' + ? 'The property recorded a returned payment' + : 'A refund was completed'; + return { + subject: 'Payment returned for your booking', + bodyText: [ + greeting(input.guestFirstName), + '', + `${description}: ${money(input.amount, input.currencyCode)}.`, + 'Processing time at your financial institution may vary.', + ].join('\n'), + }; +} + +export function failedBookingRequestPaymentEmail( + input: MoneyEmailInput & { operation: 'charge' | 'refund' }, +): BookingRequestEmailContent { + const description = input.operation === 'refund' + ? 'A payment return could not be completed' + : 'A payment attempt was not successful'; + return { + subject: input.operation === 'refund' + ? 'Payment return was not completed' + : 'Payment was not completed', + bodyText: [ + greeting(input.guestFirstName), + '', + `${description}: ${money(input.amount, input.currencyCode)}.`, + 'Please contact the property if you need assistance.', + ].join('\n'), + }; +} diff --git a/apps/api/src/modules/booking-request/booking-request-mailer.service.ts b/apps/api/src/modules/booking-request/booking-request-mailer.service.ts new file mode 100644 index 00000000..163984ab --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-mailer.service.ts @@ -0,0 +1,605 @@ +import { + ConflictException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { + auditLogs, + bookingRequestEmailDeliveries, + bookingRequests, +} from './booking-request-db.js'; +import { and, asc, eq, isNull, lte, or } from 'drizzle-orm'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { + actorFields, + type AuditActor, +} from '../../common/audit/audit-actor'; +import { DRIZZLE } from '../../database/database.module'; +import type { EmailResult } from '../agent/guest-comms/email.service'; +import { EmailService } from '../agent/guest-comms/email.service'; + +export type BookingRequestEmailKind = + typeof bookingRequestEmailDeliveries.$inferInsert['kind']; + +export type QueueBookingRequestEmail = { + propertyId: string; + bookingRequestId: string; + logicalKey: string; + kind: BookingRequestEmailKind; + recipient: string; + subject: string; + bodyText: string; +}; + +export type BookingRequestEmailDeliveryView = { + id: string; + kind: BookingRequestEmailKind; + status: typeof bookingRequestEmailDeliveries.$inferSelect['status']; + subject: string; + bodyText: string; + errorMessage: string | null; + attempts: number; + nextAttemptAt: Date | null; + lastAttemptAt: Date | null; + sentAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +type Delivery = typeof bookingRequestEmailDeliveries.$inferSelect; +type MailerDatabase = PostgresJsDatabase; +type QueueExecutor = Pick; +type DeliveryMode = 'automatic' | 'manual'; + +const CLAIM_LEASE_MS = 5 * 60 * 1000; +const SEND_TIMEOUT_MS = 60 * 1000; +const RETRY_BASE_MS = 30 * 1000; +const RETRY_MAX_MS = 2 * 60 * 1000; +const MAX_AUTOMATIC_ATTEMPTS = 5; + +@Injectable() +export class BookingRequestMailerService { + private readonly logger = new Logger(BookingRequestMailerService.name); + + constructor( + @Inject(DRIZZLE) private readonly db: MailerDatabase, + @Inject(EmailService) private readonly emailService: EmailService, + ) {} + + async queue( + input: QueueBookingRequestEmail, + executor: QueueExecutor = this.db, + ): Promise { + const logicalKey = input.logicalKey.trim(); + if (!logicalKey || logicalKey.length > 200) { + throw new Error('A valid Booking Request email logical key is required'); + } + const queuedAt = new Date(); + const [created] = await executor + .insert(bookingRequestEmailDeliveries) + .values({ + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + logicalKey, + kind: input.kind, + recipient: input.recipient.trim(), + subject: input.subject, + bodyText: input.bodyText, + status: 'pending', + attempts: 0, + automaticAttempts: 0, + nextAttemptAt: queuedAt, + }) + .onConflictDoNothing() + .returning({ id: bookingRequestEmailDeliveries.id }); + + if (created) { + await executor.insert(auditLogs).values({ + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + action: 'create', + entityType: 'booking_request_email_delivery', + entityId: created.id, + description: `Booking request ${input.kind} email queued`, + newValue: { + bookingRequestId: input.bookingRequestId, + kind: input.kind, + status: 'pending', + }, + }); + return created.id; + } + + const existing = (await executor + .select() + .from(bookingRequestEmailDeliveries) + .where(and( + eq(bookingRequestEmailDeliveries.propertyId, input.propertyId), + eq(bookingRequestEmailDeliveries.bookingRequestId, input.bookingRequestId), + eq(bookingRequestEmailDeliveries.logicalKey, logicalKey), + ))) + .find((row: Delivery) => + row.propertyId === input.propertyId + && row.bookingRequestId === input.bookingRequestId + && row.logicalKey === logicalKey); + if (!existing) throw new Error('Booking Request email could not be queued'); + return existing.id; + } + + async listForRequest( + bookingRequestId: string, + propertyId: string, + ): Promise { + await this.assertRequestScope(bookingRequestId, propertyId); + const rows = await this.db + .select() + .from(bookingRequestEmailDeliveries) + .where(and( + eq(bookingRequestEmailDeliveries.propertyId, propertyId), + eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), + )) + .orderBy(asc(bookingRequestEmailDeliveries.createdAt)); + return rows + .filter((row: Delivery) => + row.propertyId === propertyId && row.bookingRequestId === bookingRequestId) + .map((row: Delivery) => this.toView(row)); + } + + async deliver( + deliveryId: string, + bookingRequestId: string, + propertyId: string, + ): Promise { + const claimed = await this.claim(deliveryId, bookingRequestId, propertyId); + if (!claimed || claimed.status !== 'processing') return claimed; + + return this.deliverClaimed(claimed, 'automatic'); + } + + private async deliverClaimed( + claimed: Delivery, + mode: DeliveryMode, + actor?: AuditActor, + ): Promise { + const transportIdentity = this.transportIdentity(claimed.id); + const transportResult = await this.emailService.send({ + to: claimed.recipient, + subject: claimed.subject, + text: claimed.bodyText, + html: this.textAsHtml(claimed.bodyText), + idempotencyKey: transportIdentity.idempotencyKey, + messageId: transportIdentity.messageId, + }, { timeoutMs: SEND_TIMEOUT_MS }).catch(() => ({ sent: false } as EmailResult)); + + return this.finalizeAttempt(claimed, transportResult, mode, actor); + } + + async retry( + deliveryId: string, + bookingRequestId: string, + propertyId: string, + actor: AuditActor, + ): Promise { + const claimed = await this.db.transaction(async (tx) => { + const row = await this.findDelivery(tx, deliveryId, bookingRequestId, propertyId, true); + if (row.status !== 'failed') { + throw new ConflictException('Only a failed email delivery can be retried'); + } + const claimedAt = new Date(); + const leaseUntil = new Date(claimedAt.getTime() + CLAIM_LEASE_MS); + const [manualClaim] = await tx + .update(bookingRequestEmailDeliveries) + .set({ + status: 'processing', + attempts: row.attempts + 1, + automaticAttempts: 0, + claimedAt, + nextAttemptAt: leaseUntil, + lastAttemptAt: claimedAt, + errorMessage: null, + providerMessageId: null, + updatedAt: claimedAt, + }) + .where(and( + eq(bookingRequestEmailDeliveries.id, deliveryId), + eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), + eq(bookingRequestEmailDeliveries.propertyId, propertyId), + eq(bookingRequestEmailDeliveries.status, 'failed'), + )) + .returning(); + if (!manualClaim) throw new ConflictException('Email delivery retry state changed'); + await tx.insert(auditLogs).values({ + propertyId, + bookingRequestId, + action: 'update', + entityType: 'booking_request_email_delivery', + entityId: deliveryId, + ...actorFields(actor), + previousValue: { status: 'failed', attempts: row.attempts }, + newValue: { + status: 'processing', + attempts: manualClaim.attempts, + automaticAttempts: 0, + mode: 'manual', + }, + description: 'Booking request email delivery attempted', + }); + return manualClaim; + }); + + return this.toView(await this.deliverClaimed(claimed, 'manual', actor)); + } + + async deliverForRequestBestEffort( + bookingRequestId: string, + propertyId: string, + ): Promise { + try { + const now = new Date(); + const deliveries = await this.scopedDeliveries(bookingRequestId, propertyId); + for (const delivery of deliveries) { + if (!this.isAutomaticallyEligible(delivery, now)) continue; + await this.deliver(delivery.id, bookingRequestId, propertyId); + } + } catch (error: unknown) { + this.logger.error( + `Booking request ${bookingRequestId} was committed but email delivery failed`, + error instanceof Error ? error.stack : undefined, + ); + } + } + + async processPendingDeliveries(limit = 100): Promise { + const now = new Date(); + const staleBefore = new Date(now.getTime() - CLAIM_LEASE_MS); + const rows = await this.db + .select() + .from(bookingRequestEmailDeliveries) + .where(and( + or( + and( + eq(bookingRequestEmailDeliveries.status, 'pending'), + isNull(bookingRequestEmailDeliveries.claimedAt), + lte(bookingRequestEmailDeliveries.nextAttemptAt, now), + ), + and( + eq(bookingRequestEmailDeliveries.status, 'processing'), + lte(bookingRequestEmailDeliveries.claimedAt, staleBefore), + lte(bookingRequestEmailDeliveries.nextAttemptAt, now), + ), + ), + )) + .orderBy(asc(bookingRequestEmailDeliveries.nextAttemptAt)) + .limit(Math.max(1, Math.min(limit, 500))); + const candidates = rows.filter((row: Delivery) => + this.isRecoveryCandidate(row, now, staleBefore)); + for (const row of candidates) { + if (row.automaticAttempts >= MAX_AUTOMATIC_ATTEMPTS) { + await this.terminalizeExhaustedClaim(row, now, staleBefore); + } else { + await this.deliver(row.id, row.bookingRequestId, row.propertyId); + } + } + return candidates.length; + } + + private async claim( + deliveryId: string, + bookingRequestId: string, + propertyId: string, + ): Promise { + return this.db.transaction(async (tx) => { + const row = await this.findDelivery(tx, deliveryId, bookingRequestId, propertyId, true); + const now = new Date(); + if (row.status === 'sent' || row.status === 'failed') return row; + if (!this.isAutomaticallyEligible(row, now)) return undefined; + + const leaseUntil = new Date(now.getTime() + CLAIM_LEASE_MS); + const [claimed] = await tx + .update(bookingRequestEmailDeliveries) + .set({ + status: 'processing', + attempts: row.attempts + 1, + automaticAttempts: row.automaticAttempts + 1, + claimedAt: now, + nextAttemptAt: leaseUntil, + lastAttemptAt: now, + errorMessage: null, + updatedAt: now, + }) + .where(and( + eq(bookingRequestEmailDeliveries.id, deliveryId), + eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), + eq(bookingRequestEmailDeliveries.propertyId, propertyId), + eq(bookingRequestEmailDeliveries.status, row.status), + row.claimedAt + ? eq(bookingRequestEmailDeliveries.claimedAt, row.claimedAt) + : isNull(bookingRequestEmailDeliveries.claimedAt), + )) + .returning(); + if (!claimed) return undefined; + await tx.insert(auditLogs).values({ + propertyId, + bookingRequestId, + action: 'update', + entityType: 'booking_request_email_delivery', + entityId: deliveryId, + ...actorFields(), + previousValue: { status: row.status, attempts: row.attempts }, + newValue: { + status: 'processing', + attempts: claimed.attempts, + automaticAttempts: claimed.automaticAttempts, + }, + description: 'Booking request email delivery attempted', + }); + return claimed; + }); + } + + private async finalizeAttempt( + claimed: Delivery, + transportResult: EmailResult, + mode: DeliveryMode, + actor?: AuditActor, + ): Promise { + const finishedAt = new Date(); + const shouldRetry = !transportResult.sent + && !transportResult.outcomeUnknown + && mode === 'automatic' + && claimed.automaticAttempts < MAX_AUTOMATIC_ATTEMPTS; + const status: Delivery['status'] = transportResult.sent + ? 'sent' + : shouldRetry ? 'pending' : 'failed'; + const nextAttemptAt = shouldRetry + ? new Date(finishedAt.getTime() + this.backoffMs(claimed.automaticAttempts)) + : null; + const errorMessage = transportResult.sent + ? null + : transportResult.outcomeUnknown + ? 'Email delivery outcome requires manual review' + : 'Email transport failed'; + + return this.db.transaction(async (tx) => { + const [updated] = await tx + .update(bookingRequestEmailDeliveries) + .set({ + status, + claimedAt: null, + nextAttemptAt, + errorMessage, + providerMessageId: transportResult.messageId ?? null, + sentAt: transportResult.sent ? finishedAt : null, + updatedAt: finishedAt, + }) + .where(and( + eq(bookingRequestEmailDeliveries.id, claimed.id), + eq(bookingRequestEmailDeliveries.propertyId, claimed.propertyId), + eq(bookingRequestEmailDeliveries.bookingRequestId, claimed.bookingRequestId), + eq(bookingRequestEmailDeliveries.status, 'processing'), + eq(bookingRequestEmailDeliveries.claimedAt, claimed.claimedAt!), + )) + .returning(); + if (!updated) { + return this.findDelivery( + tx, + claimed.id, + claimed.bookingRequestId, + claimed.propertyId, + false, + ); + } + await tx.insert(auditLogs).values({ + propertyId: updated.propertyId, + bookingRequestId: updated.bookingRequestId, + action: 'update', + entityType: 'booking_request_email_delivery', + entityId: updated.id, + ...actorFields(actor), + previousValue: { status: 'processing' }, + newValue: { + kind: updated.kind, + status: updated.status, + attempts: updated.attempts, + automaticAttempts: updated.automaticAttempts, + ...(updated.errorMessage ? { error: updated.errorMessage } : {}), + }, + description: updated.status === 'sent' + ? 'Booking request email delivered' + : updated.status === 'pending' + ? 'Booking request email delivery scheduled for retry' + : 'Booking request email delivery failed terminally', + }); + return updated; + }); + } + + private isAutomaticallyEligible( + delivery: Delivery, + now: Date, + staleBefore = new Date(now.getTime() - CLAIM_LEASE_MS), + ): boolean { + if (delivery.automaticAttempts >= MAX_AUTOMATIC_ATTEMPTS) return false; + return this.isRecoveryCandidate(delivery, now, staleBefore); + } + + private isRecoveryCandidate( + delivery: Delivery, + now: Date, + staleBefore = new Date(now.getTime() - CLAIM_LEASE_MS), + ): boolean { + if (delivery.status === 'pending') { + return !delivery.claimedAt + && Boolean(delivery.nextAttemptAt) + && delivery.nextAttemptAt!.getTime() <= now.getTime(); + } + return delivery.status === 'processing' + && Boolean(delivery.claimedAt) + && delivery.claimedAt!.getTime() <= staleBefore.getTime() + && Boolean(delivery.nextAttemptAt) + && delivery.nextAttemptAt!.getTime() <= now.getTime(); + } + + private async terminalizeExhaustedClaim( + candidate: Delivery, + now: Date, + staleBefore: Date, + ): Promise { + return this.db.transaction(async (tx) => { + const current = await this.findDelivery( + tx, + candidate.id, + candidate.bookingRequestId, + candidate.propertyId, + true, + ); + if ( + current.automaticAttempts < MAX_AUTOMATIC_ATTEMPTS + || !this.isRecoveryCandidate(current, now, staleBefore) + ) { + return false; + } + const [updated] = await tx + .update(bookingRequestEmailDeliveries) + .set({ + status: 'failed', + claimedAt: null, + nextAttemptAt: null, + errorMessage: 'Email transport failed', + updatedAt: now, + }) + .where(and( + eq(bookingRequestEmailDeliveries.id, current.id), + eq(bookingRequestEmailDeliveries.propertyId, current.propertyId), + eq(bookingRequestEmailDeliveries.bookingRequestId, current.bookingRequestId), + eq(bookingRequestEmailDeliveries.status, current.status), + eq(bookingRequestEmailDeliveries.automaticAttempts, current.automaticAttempts), + current.claimedAt + ? eq(bookingRequestEmailDeliveries.claimedAt, current.claimedAt) + : isNull(bookingRequestEmailDeliveries.claimedAt), + )) + .returning(); + if (!updated) return false; + await tx.insert(auditLogs).values({ + propertyId: updated.propertyId, + bookingRequestId: updated.bookingRequestId, + action: 'update', + entityType: 'booking_request_email_delivery', + entityId: updated.id, + previousValue: { status: current.status }, + newValue: { + kind: updated.kind, + status: updated.status, + attempts: updated.attempts, + automaticAttempts: updated.automaticAttempts, + error: updated.errorMessage, + }, + description: 'Booking request email delivery failed terminally', + }); + return true; + }); + } + + private backoffMs(automaticAttempts: number): number { + return Math.min(RETRY_BASE_MS * (2 ** Math.max(0, automaticAttempts - 1)), RETRY_MAX_MS); + } + + /** + * SMTP and providers without server-side idempotency remain at-least-once + * across a crash after transport acceptance. Reusing this identity on every + * replay gives supporting gateways a dedupe key and all transports the same + * RFC Message-ID (or, for SES, its permitted stable custom-header equivalent). + */ + private transportIdentity(deliveryId: string): { + idempotencyKey: string; + messageId: string; + } { + return { + idempotencyKey: `booking-request-email:${deliveryId}`, + messageId: ``, + }; + } + + private async scopedDeliveries( + bookingRequestId: string, + propertyId: string, + ): Promise { + await this.assertRequestScope(bookingRequestId, propertyId); + const rows = await this.db + .select() + .from(bookingRequestEmailDeliveries) + .where(and( + eq(bookingRequestEmailDeliveries.propertyId, propertyId), + eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), + )); + return rows.filter((row: Delivery) => + row.propertyId === propertyId && row.bookingRequestId === bookingRequestId); + } + + private async assertRequestScope(bookingRequestId: string, propertyId: string): Promise { + const rows = await this.db + .select({ id: bookingRequests.id, propertyId: bookingRequests.propertyId }) + .from(bookingRequests) + .where(and( + eq(bookingRequests.id, bookingRequestId), + eq(bookingRequests.propertyId, propertyId), + )); + if (!rows.some((row: { id: string; propertyId: string }) => + row.id === bookingRequestId && row.propertyId === propertyId)) { + throw new NotFoundException(`Booking request ${bookingRequestId} not found`); + } + } + + private async findDelivery( + executor: Pick, + deliveryId: string, + bookingRequestId: string, + propertyId: string, + lock: boolean, + ): Promise { + const query = executor + .select() + .from(bookingRequestEmailDeliveries) + .where(and( + eq(bookingRequestEmailDeliveries.id, deliveryId), + eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), + eq(bookingRequestEmailDeliveries.propertyId, propertyId), + )); + const rows = lock ? await query.for('update') : await query; + const row = rows.find((candidate: Delivery) => + candidate.id === deliveryId + && candidate.bookingRequestId === bookingRequestId + && candidate.propertyId === propertyId); + if (!row) throw new NotFoundException(`Email delivery ${deliveryId} not found`); + return row; + } + + private toView(delivery: Delivery): BookingRequestEmailDeliveryView { + return { + id: delivery.id, + kind: delivery.kind, + status: delivery.status, + subject: delivery.subject, + bodyText: delivery.bodyText, + errorMessage: delivery.errorMessage, + attempts: delivery.attempts, + nextAttemptAt: delivery.nextAttemptAt, + lastAttemptAt: delivery.lastAttemptAt, + sentAt: delivery.sentAt, + createdAt: delivery.createdAt, + updatedAt: delivery.updatedAt, + }; + } + + private textAsHtml(text: string): string { + return `

${text + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('\n', '
')}

`; + } +} diff --git a/apps/api/src/modules/booking-request/booking-request-mailer.spec.ts b/apps/api/src/modules/booking-request/booking-request-mailer.spec.ts new file mode 100644 index 00000000..afbdda76 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-mailer.spec.ts @@ -0,0 +1,721 @@ +import { NotFoundException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { + auditLogs, + bookingRequestEmailDeliveries, + bookingRequests, +} from './booking-request-db.js'; +import { WEBHOOK_EVENTS } from '@telivityhaip/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuditActor } from '../../common/audit/audit-actor'; +import { PERMISSIONS_KEY } from '../auth/permissions.decorator'; +import { BookingRequestController } from './booking-request.controller'; +import { + acceptedBookingRequestEmail, + deniedBookingRequestEmail, + failedBookingRequestPaymentEmail, + paymentReceivedBookingRequestEmail, + refundedBookingRequestPaymentEmail, + requestReceivedEmail, +} from './booking-request-email.templates'; +import { BookingRequestMailerService } from './booking-request-mailer.service'; + +const PROPERTY_ID = 'aaaaaaaa-0000-4000-a000-000000000001'; +const OTHER_PROPERTY_ID = 'aaaaaaaa-0000-4000-a000-000000000002'; +const REQUEST_ID = 'bbbbbbbb-0000-4000-a000-000000000001'; +const DELIVERY_ID = 'cccccccc-0000-4000-a000-000000000001'; + +type Delivery = typeof bookingRequestEmailDeliveries.$inferSelect; + +function delivery(overrides: Partial = {}): Delivery { + const now = new Date('2026-08-25T00:00:00.000Z'); + return { + id: DELIVERY_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + logicalKey: 'request:receipt', + kind: 'receipt', + status: 'pending', + recipient: 'guest@example.com', + subject: 'We received your booking request', + bodyText: 'Hello Ada. We received your booking request.', + errorMessage: null, + attempts: 0, + automaticAttempts: 0, + claimedAt: null, + nextAttemptAt: now, + lastAttemptAt: null, + providerMessageId: null, + sentAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +type HarnessOptions = { + failAuditDescription?: string; + failAuditTimes?: number; + casWinner?: Delivery; +}; + +function createHarness(seed: Delivery[] = [], options: HarnessOptions = {}) { + const state = { + requests: [{ id: REQUEST_ID, propertyId: PROPERTY_ID }], + deliveries: seed.map((row) => ({ ...row })), + audits: [] as Array>, + deliveryUpdates: [] as Array>, + }; + let remainingAuditFailures = options.failAuditTimes ?? 0; + + const insert = vi.fn((table: unknown) => ({ + values: (values: Record) => { + if (table === auditLogs) { + if ( + remainingAuditFailures > 0 + && values.description === options.failAuditDescription + ) { + remainingAuditFailures -= 1; + throw new Error('audit write failed'); + } + state.audits.push({ ...values }); + return Promise.resolve(); + } + if (table !== bookingRequestEmailDeliveries) throw new Error('unexpected insert'); + const existing = state.deliveries.find((row) => + row.propertyId === values.propertyId + && row.bookingRequestId === values.bookingRequestId + && row.logicalKey === values.logicalKey); + const created = existing ? undefined : delivery({ + ...values, + id: values.id ?? DELIVERY_ID, + status: values.status ?? 'pending', + attempts: values.attempts ?? 0, + }); + if (created) state.deliveries.push(created); + const result = existing ? [] : [created!]; + return { + onConflictDoNothing: () => ({ returning: async () => result }), + returning: async () => result, + }; + }, + })); + + const select = vi.fn(() => ({ + from: (table: unknown) => { + const rows = table === bookingRequests ? state.requests : state.deliveries; + const query: any = { + where: () => query, + orderBy: () => query, + limit: async () => rows, + for: async () => rows, + then: (resolve: (value: any) => unknown) => Promise.resolve(rows).then(resolve), + }; + return query; + }, + })); + + const conditionContains = (condition: unknown, value: string): boolean => { + const seen = new WeakSet(); + const visit = (candidate: unknown): boolean => { + if (candidate === value) return true; + if (!candidate || typeof candidate !== 'object') return false; + if (seen.has(candidate)) return false; + seen.add(candidate); + return Object.values(candidate).some(visit); + }; + return visit(condition); + }; + const update = vi.fn((table: unknown) => ({ + set: (changes: Record) => ({ + where: (condition: unknown) => ({ + returning: async () => { + if (table !== bookingRequestEmailDeliveries) return []; + const current = state.deliveries.find((row) => conditionContains(condition, row.id)); + if (!current) return []; + if ( + options.casWinner + && Object.hasOwn(changes, 'providerMessageId') + && current.status === 'processing' + ) { + Object.assign(current, options.casWinner); + return []; + } + state.deliveryUpdates.push({ ...changes }); + Object.assign(current, changes); + return [current]; + }, + then: (resolve: (value: any) => unknown) => { + const current = state.deliveries.find((row) => conditionContains(condition, row.id)); + if (table === bookingRequestEmailDeliveries && current) { + state.deliveryUpdates.push({ ...changes }); + Object.assign(current, changes); + } + return Promise.resolve(undefined).then(resolve); + }, + }), + }), + })); + + const db: any = { + insert, + select, + update, + transaction: async (work: (tx: any) => unknown) => { + const deliveriesBefore = structuredClone(state.deliveries); + const auditsBefore = structuredClone(state.audits); + const deliveryUpdatesBefore = structuredClone(state.deliveryUpdates); + try { + return await work(db); + } catch (error) { + state.deliveries.splice(0, state.deliveries.length, ...deliveriesBefore); + state.audits.splice(0, state.audits.length, ...auditsBefore); + state.deliveryUpdates.splice( + 0, + state.deliveryUpdates.length, + ...deliveryUpdatesBefore, + ); + throw error; + } + }, + }; + const emailService = { send: vi.fn() }; + const service = new BookingRequestMailerService(db, emailService as any); + return { state, emailService, service }; +} + +describe('Booking Request email templates', () => { + const common = { + guestFirstName: 'Ada', + arrivalDate: '2026-09-10', + departureDate: '2026-09-12', + }; + + it('renders receipt, accepted, and denied messages without private links or identifiers', () => { + const messages = [ + requestReceivedEmail(common), + acceptedBookingRequestEmail({ ...common, acceptedTotal: '420.00', currencyCode: 'EUR' }), + deniedBookingRequestEmail(common), + ]; + + for (const message of messages) { + const content = `${message.subject}\n${message.bodyText}`; + expect(content).toContain('Ada'); + expect(content).not.toMatch(/https?:\/\//i); + expect(content).not.toMatch(/manage|cancel|sign[ -]?in|token|setupintent|paymentmethod|customer_/i); + expect(content).not.toContain(REQUEST_ID); + } + }); + + it('renders captured, refunded/returned, and failed payment messages with guest-safe facts only', () => { + const messages = [ + paymentReceivedBookingRequestEmail({ + guestFirstName: 'Ada', amount: '100.00', currencyCode: 'EUR', source: 'external', + }), + refundedBookingRequestPaymentEmail({ + guestFirstName: 'Ada', amount: '40.00', currencyCode: 'EUR', source: 'external_return', + }), + failedBookingRequestPaymentEmail({ + guestFirstName: 'Ada', amount: '25.00', currencyCode: 'EUR', operation: 'charge', + }), + ]; + + expect(messages[0].bodyText).toContain('100.00 EUR'); + expect(messages[1].bodyText).toContain('40.00 EUR'); + for (const message of messages) { + const content = `${message.subject}\n${message.bodyText}`; + expect(content).not.toMatch(/https?:\/\//i); + expect(content).not.toMatch(/reference|provider|authentication|stripe|secret|token/i); + } + }); +}); + +describe('BookingRequestMailerService', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('persists one pending delivery per stable logical action before sending', async () => { + const h = createHarness(); + h.emailService.send.mockImplementation(async () => { + expect(h.state.deliveries[0]).toMatchObject({ + status: 'processing', attempts: 1, automaticAttempts: 1, + }); + return { sent: true, provider: 'smtp', messageId: 'provider-message-id' }; + }); + const input = { + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + logicalKey: 'request:receipt', + kind: 'receipt' as const, + recipient: 'guest@example.com', + subject: 'We received your booking request', + bodyText: 'Hello Ada. We received your booking request.', + }; + + const firstId = await h.service.queue(input); + const replayId = await h.service.queue(input); + expect(firstId).toBe(DELIVERY_ID); + expect(replayId).toBe(DELIVERY_ID); + expect(h.state.deliveries).toHaveLength(1); + + const result = await h.service.deliver(DELIVERY_ID, REQUEST_ID, PROPERTY_ID); + expect(result).toMatchObject({ + status: 'sent', attempts: 1, errorMessage: null, providerMessageId: 'provider-message-id', + }); + expect(h.emailService.send).toHaveBeenCalledOnce(); + const message = h.emailService.send.mock.calls[0]?.[0]; + expect(message).toMatchObject({ + idempotencyKey: `booking-request-email:${DELIVERY_ID}`, + messageId: ``, + }); + }); + + it('schedules a safe bounded-backoff failure without exposing provider errors', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-25T00:00:00.000Z')); + const h = createHarness([delivery({ nextAttemptAt: new Date(0) })]); + h.emailService.send.mockRejectedValue( + new Error('smtp password secret-token and pm_123 leaked'), + ); + + const failed = await h.service.deliver(DELIVERY_ID, REQUEST_ID, PROPERTY_ID); + expect(failed).toMatchObject({ + status: 'pending', + attempts: 1, + automaticAttempts: 1, + errorMessage: 'Email transport failed', + nextAttemptAt: new Date('2026-08-25T00:00:30.000Z'), + }); + expect(failed?.errorMessage).not.toMatch(/secret|pm_123|password/i); + expect(await h.service.processPendingDeliveries()).toBe(0); + expect(h.emailService.send).toHaveBeenCalledOnce(); + }); + + it('lists and retries only within the supplied property and request scope', async () => { + const h = createHarness([ + delivery(), + delivery({ + id: 'cccccccc-0000-4000-a000-000000000002', + propertyId: OTHER_PROPERTY_ID, + }), + ]); + + await expect(h.service.listForRequest(REQUEST_ID, OTHER_PROPERTY_ID)) + .rejects.toBeInstanceOf(NotFoundException); + await expect(h.service.retry(DELIVERY_ID, REQUEST_ID, OTHER_PROPERTY_ID, {})) + .rejects.toBeInstanceOf(NotFoundException); + const own = await h.service.listForRequest(REQUEST_ID, PROPERTY_ID); + expect(own).toHaveLength(1); + expect(own[0]).toEqual({ + id: DELIVERY_ID, + kind: 'receipt', + status: 'pending', + subject: 'We received your booking request', + bodyText: 'Hello Ada. We received your booking request.', + errorMessage: null, + attempts: 0, + nextAttemptAt: new Date('2026-08-25T00:00:00.000Z'), + lastAttemptAt: null, + sentAt: null, + createdAt: new Date('2026-08-25T00:00:00.000Z'), + updatedAt: new Date('2026-08-25T00:00:00.000Z'), + }); + expect(own[0]).not.toHaveProperty('logicalKey'); + expect(own[0]).not.toHaveProperty('propertyId'); + expect(own[0]).not.toHaveProperty('bookingRequestId'); + expect(own[0]).not.toHaveProperty('claimedAt'); + expect(own[0]).not.toHaveProperty('providerMessageId'); + }); + + it('recovers only due pending and stale processing deliveries, never terminal failures', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-25T00:10:00.000Z')); + const stale = new Date(Date.now() - 10 * 60 * 1000); + const future = new Date(Date.now() + 60_000); + const h = createHarness([ + delivery(), + delivery({ + id: 'cccccccc-0000-4000-a000-000000000002', + status: 'processing', + claimedAt: stale, + nextAttemptAt: stale, + attempts: 1, + automaticAttempts: 1, + }), + delivery({ + id: 'cccccccc-0000-4000-a000-000000000003', status: 'pending', nextAttemptAt: future, + }), + delivery({ id: 'cccccccc-0000-4000-a000-000000000004', status: 'failed' }), + delivery({ id: 'cccccccc-0000-4000-a000-000000000005', status: 'sent' }), + delivery({ + id: 'cccccccc-0000-4000-a000-000000000006', status: 'pending', nextAttemptAt: null, + }), + ]); + h.emailService.send.mockResolvedValue({ sent: true, provider: 'smtp' }); + + expect(await h.service.processPendingDeliveries()).toBe(2); + expect(h.emailService.send).toHaveBeenCalledTimes(2); + expect(h.state.deliveries.find((row) => row.id.endsWith('0003'))?.status).toBe('pending'); + expect(h.state.deliveries.find((row) => row.id.endsWith('0004'))?.status).toBe('failed'); + expect(h.state.deliveries.find((row) => row.id.endsWith('0006'))?.status).toBe('pending'); + }); + + it('caps permanent automatic failures and later actions do not hot-loop terminal mail', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-25T00:00:00.000Z')); + const h = createHarness([delivery()]); + h.emailService.send.mockResolvedValue({ sent: false, provider: 'smtp', error: 'no route' }); + const expectedBackoffs = [30_000, 60_000, 120_000, 120_000]; + + for (let attempt = 1; attempt <= 5; attempt += 1) { + const attemptedAt = Date.now(); + await h.service.processPendingDeliveries(); + if (attempt < 5) { + expect(h.state.deliveries[0]).toMatchObject({ + status: 'pending', automaticAttempts: attempt, + }); + expect(h.state.deliveries[0]!.nextAttemptAt!.getTime() - attemptedAt) + .toBe(expectedBackoffs[attempt - 1]); + vi.setSystemTime(h.state.deliveries[0]!.nextAttemptAt!); + } + } + + expect(h.state.deliveries[0]).toMatchObject({ + status: 'failed', attempts: 5, automaticAttempts: 5, nextAttemptAt: null, + }); + await h.service.processPendingDeliveries(); + await h.service.deliverForRequestBestEffort(REQUEST_ID, PROPERTY_ID); + expect(h.emailService.send).toHaveBeenCalledTimes(5); + }); + + it('terminalizes a due stale claim that already consumed the automatic attempt limit', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-25T00:10:00.000Z')); + const stale = new Date('2026-08-25T00:00:00.000Z'); + const h = createHarness([delivery({ + status: 'processing', + attempts: 5, + automaticAttempts: 5, + claimedAt: stale, + nextAttemptAt: stale, + })]); + + expect(await h.service.processPendingDeliveries()).toBe(1); + expect(h.state.deliveries[0]).toMatchObject({ + status: 'failed', + attempts: 5, + automaticAttempts: 5, + claimedAt: null, + nextAttemptAt: null, + errorMessage: 'Email transport failed', + }); + expect(h.emailService.send).not.toHaveBeenCalled(); + expect(h.state.audits.map((audit) => audit.description)).toContain( + 'Booking request email delivery failed terminally', + ); + }); + + it('atomically reserves a manual retry so an automatic worker cannot steal it', async () => { + const actor: AuditActor = { + userId: 'dddddddd-0000-4000-a000-000000000001', + userEmail: 'agent@example.com', + ipAddress: '203.0.113.8', + }; + const h = createHarness([delivery({ + status: 'failed', + attempts: 5, + automaticAttempts: 5, + nextAttemptAt: null, + errorMessage: 'Email transport failed', + })]); + let finishSend!: () => void; + h.emailService.send.mockReturnValue(new Promise((resolve) => { + finishSend = () => resolve({ + sent: true, provider: 'smtp', messageId: 'provider-retry-id', + }); + })); + + const retrying = h.service.retry(DELIVERY_ID, REQUEST_ID, PROPERTY_ID, actor); + await vi.waitFor(() => expect(h.emailService.send).toHaveBeenCalledOnce()); + + expect(h.state.deliveries[0]).toMatchObject({ + status: 'processing', + attempts: 6, + automaticAttempts: 0, + claimedAt: expect.any(Date), + nextAttemptAt: expect.any(Date), + }); + expect(h.state.deliveryUpdates).not.toContainEqual(expect.objectContaining({ + status: 'pending', + })); + expect(await h.service.processPendingDeliveries()).toBe(0); + expect(h.emailService.send).toHaveBeenCalledOnce(); + + finishSend(); + const retried = await retrying; + + expect(retried).toMatchObject({ status: 'sent', attempts: 6, errorMessage: null }); + expect(retried).not.toHaveProperty('providerMessageId'); + expect(h.state.audits.map((audit) => audit.description)).toEqual([ + 'Booking request email delivery attempted', + 'Booking request email delivered', + ]); + expect(h.state.audits).toHaveLength(2); + expect(h.state.audits.every((audit) => + audit.userId === actor.userId + && audit.userEmail === actor.userEmail + && audit.ipAddress === actor.ipAddress)).toBe(true); + }); + + it('rolls back a manual claim when its attributed audit cannot be persisted', async () => { + const h = createHarness([delivery({ + status: 'failed', + attempts: 5, + automaticAttempts: 5, + nextAttemptAt: null, + errorMessage: 'Email transport failed', + })], { + failAuditDescription: 'Booking request email delivery attempted', + failAuditTimes: 1, + }); + + await expect(h.service.retry(DELIVERY_ID, REQUEST_ID, PROPERTY_ID, { + userId: 'dddddddd-0000-4000-a000-000000000001', + })).rejects.toThrow('audit write failed'); + expect(h.state.deliveries[0]).toMatchObject({ + status: 'failed', attempts: 5, automaticAttempts: 5, + }); + expect(h.emailService.send).not.toHaveBeenCalled(); + expect(h.state.audits).toHaveLength(0); + }); + + it('terminalizes a failed manual attempt and attributes both audits to staff', async () => { + const actor: AuditActor = { + userId: 'dddddddd-0000-4000-a000-000000000001', + userEmail: 'agent@example.com', + ipAddress: '203.0.113.8', + }; + const h = createHarness([delivery({ + status: 'failed', + attempts: 5, + automaticAttempts: 5, + nextAttemptAt: null, + errorMessage: 'Email transport failed', + })]); + h.emailService.send.mockResolvedValue({ + sent: false, provider: 'smtp', error: 'temporary provider detail', + }); + + const result = await h.service.retry(DELIVERY_ID, REQUEST_ID, PROPERTY_ID, actor); + + expect(result).toMatchObject({ + status: 'failed', + attempts: 6, + nextAttemptAt: null, + errorMessage: 'Email transport failed', + }); + expect(await h.service.processPendingDeliveries()).toBe(0); + expect(h.state.audits.map((audit) => audit.description)).toEqual([ + 'Booking request email delivery attempted', + 'Booking request email delivery failed terminally', + ]); + expect(h.state.audits.every((audit) => audit.userId === actor.userId)).toBe(true); + }); + + it('returns the persisted manual-retry winner when final compare-and-set loses', async () => { + const actor: AuditActor = { + userId: 'dddddddd-0000-4000-a000-000000000001', + userEmail: 'agent@example.com', + ipAddress: '203.0.113.8', + }; + const winner = delivery({ + status: 'sent', + attempts: 7, + automaticAttempts: 1, + claimedAt: null, + nextAttemptAt: null, + sentAt: new Date('2026-08-25T00:00:10.000Z'), + providerMessageId: 'persisted-winner-provider-id', + }); + const h = createHarness([delivery({ + status: 'failed', attempts: 5, automaticAttempts: 5, nextAttemptAt: null, + })], { casWinner: winner }); + h.emailService.send.mockResolvedValue({ + sent: false, provider: 'smtp', error: 'loser result', + }); + + const result = await h.service.retry(DELIVERY_ID, REQUEST_ID, PROPERTY_ID, actor); + + expect(result).toMatchObject({ status: 'sent', attempts: 7 }); + expect(result).not.toHaveProperty('providerMessageId'); + expect(h.state.audits.map((audit) => audit.description)).toEqual([ + 'Booking request email delivery attempted', + ]); + expect(h.state.audits[0]).toMatchObject(actor); + }); + + it('rolls back the delivery claim when its attempt audit cannot be persisted', async () => { + const h = createHarness([delivery({ nextAttemptAt: new Date(0) })], { + failAuditDescription: 'Booking request email delivery attempted', + failAuditTimes: 1, + }); + + await expect(h.service.deliver(DELIVERY_ID, REQUEST_ID, PROPERTY_ID)) + .rejects.toThrow('audit write failed'); + expect(h.state.deliveries[0]).toMatchObject({ + status: 'pending', attempts: 0, automaticAttempts: 0, claimedAt: null, + }); + expect(h.emailService.send).not.toHaveBeenCalled(); + expect(h.state.audits).toHaveLength(0); + }); + + it('does not let a normal slow send be reclaimed before its lease expires', async () => { + let finishSend!: (result: { sent: boolean; provider: string; messageId: string }) => void; + const pendingSend = new Promise<{ sent: boolean; provider: string; messageId: string }>( + (resolve) => { finishSend = resolve; }, + ); + const h = createHarness([delivery({ nextAttemptAt: new Date(0) })]); + h.emailService.send.mockReturnValue(pendingSend); + + const first = h.service.deliver(DELIVERY_ID, REQUEST_ID, PROPERTY_ID); + await vi.waitFor(() => expect(h.state.deliveries[0]?.status).toBe('processing')); + expect(await h.service.processPendingDeliveries()).toBe(0); + expect(await h.service.deliver(DELIVERY_ID, REQUEST_ID, PROPERTY_ID)).toBeUndefined(); + expect(h.emailService.send).toHaveBeenCalledOnce(); + + finishSend({ sent: true, provider: 'smtp', messageId: 'slow-provider-id' }); + await expect(first).resolves.toMatchObject({ status: 'sent' }); + }); + + it('never releases a claim while the bounded provider operation is still active', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-25T00:00:00.000Z')); + const h = createHarness([delivery()]); + let providerSettled = false; + let settleProvider!: () => void; + h.emailService.send.mockReturnValue(new Promise((resolve) => { + settleProvider = () => { + providerSettled = true; + resolve({ + sent: false, + provider: 'smtp', + error: 'Email transport timed out', + outcomeUnknown: true, + }); + }; + })); + + const attempt = h.service.deliver(DELIVERY_ID, REQUEST_ID, PROPERTY_ID); + await vi.advanceTimersByTimeAsync(60_000); + + expect(providerSettled).toBe(false); + expect(h.state.deliveries[0]).toMatchObject({ + status: 'processing', + claimedAt: new Date('2026-08-25T00:00:00.000Z'), + nextAttemptAt: new Date('2026-08-25T00:05:00.000Z'), + }); + expect(await h.service.processPendingDeliveries()).toBe(0); + expect(h.emailService.send).toHaveBeenCalledOnce(); + expect(h.emailService.send).toHaveBeenCalledWith( + expect.any(Object), + { timeoutMs: 60_000 }, + ); + + settleProvider(); + await expect(attempt).resolves.toMatchObject({ + status: 'failed', + claimedAt: null, + nextAttemptAt: null, + errorMessage: 'Email delivery outcome requires manual review', + }); + expect(providerSettled).toBe(true); + }); + + it('reuses stable transport identity after an ambiguous committed send', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-25T00:00:00.000Z')); + const h = createHarness([delivery()], { + failAuditDescription: 'Booking request email delivered', + failAuditTimes: 1, + }); + h.emailService.send.mockResolvedValue({ sent: true, provider: 'smtp', messageId: 'provider-id' }); + + await expect(h.service.deliver(DELIVERY_ID, REQUEST_ID, PROPERTY_ID)) + .rejects.toThrow('audit write failed'); + expect(h.state.deliveries[0]).toMatchObject({ status: 'processing', attempts: 1 }); + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + + await expect(h.service.deliver(DELIVERY_ID, REQUEST_ID, PROPERTY_ID)) + .resolves.toMatchObject({ status: 'sent', attempts: 2 }); + expect(h.emailService.send).toHaveBeenCalledTimes(2); + const identities = h.emailService.send.mock.calls.map((call) => ({ + idempotencyKey: call[0].idempotencyKey, + messageId: call[0].messageId, + })); + expect(new Set(identities.map((identity) => JSON.stringify(identity))).size).toBe(1); + }); + + it('returns the persisted winner and writes no final audit when its final CAS loses', async () => { + const winner = delivery({ + status: 'sent', + attempts: 2, + automaticAttempts: 2, + claimedAt: null, + nextAttemptAt: null, + sentAt: new Date('2026-08-25T00:00:10.000Z'), + providerMessageId: 'winner-provider-id', + }); + const h = createHarness([delivery({ nextAttemptAt: new Date(0) })], { casWinner: winner }); + h.emailService.send.mockResolvedValue({ + sent: false, provider: 'smtp', error: 'loser result', + }); + + const result = await h.service.deliver(DELIVERY_ID, REQUEST_ID, PROPERTY_ID); + + expect(result).toEqual(winner); + expect(h.state.audits.map((audit) => audit.description)).toEqual([ + 'Booking request email delivery attempted', + ]); + }); +}); + +describe('Booking Request email API and webhook contract', () => { + it('uses reservation read for history and reservation write for retry', () => { + const reflector = new Reflector(); + expect(reflector.get(PERMISSIONS_KEY, BookingRequestController.prototype.listEmailDeliveries)) + .toEqual(['reservations.read']); + expect(reflector.get(PERMISSIONS_KEY, BookingRequestController.prototype.retryEmailDelivery)) + .toEqual(['reservations.write']); + }); + + it('forwards the authenticated audit actor into a manual retry', async () => { + const actor: AuditActor = { + userId: 'dddddddd-0000-4000-a000-000000000001', + userEmail: 'agent@example.com', + ipAddress: '203.0.113.8', + }; + const mailer = { retry: vi.fn().mockResolvedValue({ status: 'sent' }) }; + const controller = new BookingRequestController( + {} as ConstructorParameters[0], + {} as ConstructorParameters[1], + mailer as unknown as ConstructorParameters[2], + ); + await (controller.retryEmailDelivery as unknown as (...args: unknown[]) => Promise)( + REQUEST_ID, + DELIVERY_ID, + PROPERTY_ID, + actor, + ); + expect(mailer.retry).toHaveBeenCalledWith(DELIVERY_ID, REQUEST_ID, PROPERTY_ID, actor); + }); + + it('publishes typed request lifecycle event names', () => { + expect(WEBHOOK_EVENTS).toMatchObject({ + 'booking_request.created': 'booking_request.created', + 'booking_request.accepted': 'booking_request.accepted', + 'booking_request.denied': 'booking_request.denied', + }); + }); +}); diff --git a/apps/api/src/modules/booking-request/booking-request-money.spec.ts b/apps/api/src/modules/booking-request/booking-request-money.spec.ts new file mode 100644 index 00000000..f8be551f --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-money.spec.ts @@ -0,0 +1,144 @@ +import Decimal from 'decimal.js'; +import { describe, expect, it } from 'vitest'; +import { + assertAllocationAmount, + assertDenialMoneyResolved, + assertLedgerCurrencySupported, + resolveAcceptedTotal, + resolveInstallmentAmount, +} from './booking-request-money'; + +describe('booking request money', () => { + it('returns supported ISO currency exponents and rejects unknown or scale-three ISO currencies', () => { + expect(assertLedgerCurrencySupported(' usd ')).toBe(2); + expect(assertLedgerCurrencySupported('JPY')).toBe(0); + for (const currencyCode of ['BHD', 'IQD', 'KWD', 'LYD', 'OMR', 'TND']) { + expect(() => assertLedgerCurrencySupported(currencyCode)) + .toThrow(new RegExp(`${currencyCode}.*scale-two payment ledger`, 'i')); + } + expect(() => assertLedgerCurrencySupported('ZZZ')).toThrow(/unsupported ISO-4217 currency/i); + }); + + it('requires a reason for a custom accepted price', () => { + expect(() => resolveAcceptedTotal({ + source: 'custom', + submittedTotal: '1000.00', + currentTotal: '1100.00', + customTotal: '1050.00', + })).toThrow(/reason/); + }); + + it('resolves submitted, current, and custom accepted totals as Decimal money', () => { + expect(resolveAcceptedTotal({ + source: 'submitted', submittedTotal: '1000.00', currentTotal: '1100.00', + })).toMatchObject({ source: 'submitted' }); + expect(resolveAcceptedTotal({ + source: 'current', submittedTotal: '1000.00', currentTotal: '1100.00', + }).total).toEqual(new Decimal('1100.00')); + expect(resolveAcceptedTotal({ + source: 'custom', submittedTotal: '1000.00', currentTotal: '1100.00', + customTotal: '1050.00', customReason: 'Agreed rate', + })).toMatchObject({ source: 'custom', customReason: 'Agreed rate' }); + }); + + it('rejects non-positive custom totals', () => { + expect(() => resolveAcceptedTotal({ + source: 'custom', customTotal: '0', customReason: 'No charge', + })).toThrow(/positive/); + expect(() => resolveAcceptedTotal({ + source: 'custom', customTotal: '-1', customReason: 'No charge', + })).toThrow(/positive/); + }); + + it('resolves fixed and percentage installments with currency rounding', () => { + expect(resolveInstallmentAmount({ total: '100.00', fixedAmount: '35.00' })) + .toEqual(new Decimal('35.00')); + expect(resolveInstallmentAmount({ total: '100.00', percentage: '33.333' })) + .toEqual(new Decimal('33.33')); + }); + + it('rounds percentage installments to the supplied ISO currency exponent', () => { + expect(resolveInstallmentAmount({ + total: '101', percentage: '50', currencyExponent: 0, + })).toEqual(new Decimal('51')); + expect(resolveInstallmentAmount({ + total: '101.00', percentage: '50', currencyExponent: 2, + })).toEqual(new Decimal('50.50')); + }); + + it('rejects invalid installment amounts and allocations', () => { + expect(() => resolveInstallmentAmount({ total: '100', fixedAmount: '0' })).toThrow(/positive/); + expect(() => resolveInstallmentAmount({ total: '100', fixedAmount: '50', allocatedAmount: '51' })) + .toThrow(/allocat/i); + expect(() => assertAllocationAmount({ amount: '101', movementAmount: '100', installmentAmount: '200' })) + .toThrow(/movement/); + expect(() => assertAllocationAmount({ amount: '101', movementAmount: '200', installmentAmount: '100' })) + .toThrow(/installment/); + expect(() => assertAllocationAmount({ + amount: '60', movementAmount: '100', installmentAmount: '100', + alreadyAllocatedMovementAmount: '50', + })).toThrow(/movement/); + expect(() => assertAllocationAmount({ + amount: '60', movementAmount: '100', installmentAmount: '100', + alreadyAllocatedInstallmentAmount: '50', + })).toThrow(/installment/); + }); + + it('requires each captured movement to be fully resolved on denial', () => { + expect(() => assertDenialMoneyResolved( + [{ id: 'payment-1', status: 'captured', amount: '100.00' }], + [ + { paymentId: 'payment-1', type: 'external_return', amount: '40.00' }, + { paymentId: 'payment-1', type: 'retained', amount: '60.00', reason: 'Cancellation fee retained' }, + ], + )).not.toThrow(); + expect(() => assertDenialMoneyResolved( + [{ id: 'payment-1', status: 'captured', amount: '100.00' }], + [{ paymentId: 'payment-1', type: 'retained', amount: '99.99', reason: 'Partial retention' }], + )).toThrow(/payment-1/); + }); + + it('rejects zero and over-refunded resolutions', () => { + expect(() => assertDenialMoneyResolved( + [{ id: 'payment-1', status: 'captured', amount: '100.00' }], + [{ paymentId: 'payment-1', type: 'refund', amount: '0' }], + )).toThrow(/positive/); + expect(() => assertDenialMoneyResolved( + [{ id: 'payment-1', status: 'captured', amount: '100.00' }], + [{ paymentId: 'payment-1', type: 'refund', amount: '100.01' }], + )).toThrow(/payment-1/); + }); + + it('requires a non-blank reason when money is retained', () => { + expect(() => assertDenialMoneyResolved( + [{ id: 'payment-1', status: 'captured', amount: '100.00' }], + [{ paymentId: 'payment-1', type: 'retained', amount: '100.00' }], + )).toThrow(/reason/); + expect(() => assertDenialMoneyResolved( + [{ id: 'payment-1', status: 'captured', amount: '100.00' }], + [{ paymentId: 'payment-1', type: 'retained', amount: '100.00', reason: ' ' }], + )).toThrow(/reason/); + }); + + it('does not require a fully refunded movement whose net amount is zero', () => { + expect(() => assertDenialMoneyResolved( + [{ id: 'payment-1', status: 'captured', amount: '100.00', netAmount: '0.00' }], + [], + )).not.toThrow(); + expect(() => assertDenialMoneyResolved( + [{ id: 'payment-1', status: 'captured', amount: '100.00', netAmount: '0.00' }], + [{ paymentId: 'payment-1', type: 'refund', amount: '100.00' }], + )).not.toThrow(); + }); + + it('rejects negative captured or net movement amounts', () => { + expect(() => assertDenialMoneyResolved( + [{ id: 'payment-1', status: 'captured', amount: '-1.00' }], + [], + )).toThrow(/negative/); + expect(() => assertDenialMoneyResolved( + [{ id: 'payment-1', status: 'captured', amount: '100.00', netAmount: '-0.01' }], + [], + )).toThrow(/negative/); + }); +}); diff --git a/apps/api/src/modules/booking-request/booking-request-money.ts b/apps/api/src/modules/booking-request/booking-request-money.ts new file mode 100644 index 00000000..2e80ffab --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-money.ts @@ -0,0 +1,328 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import Decimal from 'decimal.js'; + +export type MoneyValue = string | Decimal; +export type BookingRequestPriceSource = 'submitted' | 'current' | 'custom'; + +export type ResolveAcceptedTotalInput = { + source: BookingRequestPriceSource; + submittedTotal?: MoneyValue | null; + currentTotal?: MoneyValue | null; + customTotal?: MoneyValue | null; + customReason?: string | null; +}; + +export type AcceptedTotalResolution = { + source: BookingRequestPriceSource; + total: Decimal; + customReason?: string; +}; + +export type ResolveInstallmentAmountInput = { + /** Accepted total against which a percentage installment is resolved. */ + total?: MoneyValue | null; + acceptedTotal?: MoneyValue | null; + fixedAmount?: MoneyValue | null; + percentage?: MoneyValue | null; + /** Optional existing allocation, useful when validating an edited plan. */ + allocatedAmount?: MoneyValue | null; + /** ISO 4217 minor-unit exponent; defaults to the scale-two ledger. */ + currencyExponent?: number; +}; + +export type AllocationAmountInput = { + amount: MoneyValue; + movementAmount: MoneyValue; + installmentAmount: MoneyValue; + /** Amount already allocated against this payment movement. */ + alreadyAllocatedMovementAmount?: MoneyValue | null; + /** Amount already allocated against this installment. */ + alreadyAllocatedInstallmentAmount?: MoneyValue | null; + /** Alternative form when the caller has already computed remaining capacity. */ + remainingMovementAmount?: MoneyValue | null; + remainingInstallmentAmount?: MoneyValue | null; +}; + +export type CapturedMovement = { + id?: string; + paymentId?: string; + status?: string; + type?: string; + amount: MoneyValue; + /** Use this when the caller has already calculated refunds against a movement. */ + netAmount?: MoneyValue | null; +}; + +export type DenialResolution = { + id?: string; + paymentId?: string; + movementId?: string; + type: 'refund' | 'external_return' | 'retained'; + amount: MoneyValue; + reason?: string | null; +}; + +type Iso4217MinorUnit = 0 | 2 | 3 | 4 | null; +type CurrencyErrorFactory = (message: string) => Error; + +function iso4217Currencies( + minorUnit: Iso4217MinorUnit, + currencyCodes: string, +): Array<[string, Iso4217MinorUnit]> { + return currencyCodes.split(' ').map((currencyCode) => [currencyCode, minorUnit]); +} + +/** + * ISO 4217 List One, published 2026-01-01. `null` represents ISO's N.A. + * minor-unit entry, which cannot be represented by this numeric ledger. + */ +const ISO_4217_MINOR_UNITS = new Map([ + ...iso4217Currencies(0, 'BIF CLP DJF GNF ISK JPY KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF'), + ...iso4217Currencies(2, 'AED AFN ALL AMD AOA ARS AUD AWG AZN BAM BBD BDT BMD BND BOB BOV BRL BSD BTN BWP BYN BZD CAD CDF CHE CHF CHW CNY COP COU CRC CUP CVE CZK DKK DOP DZD EGP ERN ETB EUR FJD FKP GBP GEL GHS GIP GMD GTQ GYD HKD HNL HTG HUF IDR ILS INR IRR JMD KES KGS KHR KPW KYD KZT LAK LBP LKR LRD LSL MAD MDL MGA MKD MMK MNT MOP MRU MUR MVR MWK MXN MXV MYR MZN NAD NGN NIO NOK NPR NZD PAB PEN PGK PHP PKR PLN QAR RON RSD RUB SAR SBD SCR SDG SEK SGD SHP SLE SOS SRD SSP STN SVC SYP SZL THB TJS TMT TOP TRY TTD TWD TZS UAH USD USN UYU UZS VED VES WST XAD XCD XCG YER ZAR ZMW ZWG'), + ...iso4217Currencies(3, 'BHD IQD JOD KWD LYD OMR TND'), + ...iso4217Currencies(4, 'CLF UYW'), + ...iso4217Currencies(null, 'XAG XAU XBA XBB XBC XBD XDR XPD XPT XSU XTS XUA XXX'), +]); + +/** + * The payment ledger is stored as numeric(12,2), so only ISO currencies + * whose minor units fit that scale can enter booking-request money flows. + */ +export function assertLedgerCurrencySupported( + currencyCode: string, + errorFactory: CurrencyErrorFactory = (message) => new BadRequestException(message), +): number { + const normalized = currencyCode.trim().toUpperCase(); + const exponent = ISO_4217_MINOR_UNITS.get(normalized); + if (exponent == null) { + throw errorFactory(`Unsupported ISO-4217 currency code '${currencyCode}'`); + } + if (exponent > 2) { + throw errorFactory( + `${normalized} is not supported by the scale-two payment ledger`, + ); + } + return exponent; +} + +function decimal(value: MoneyValue, field: string): Decimal { + try { + const result = new Decimal(value); + if (!result.isFinite()) throw new Error('not finite'); + return result; + } catch { + throw new ConflictException(`Invalid ${field} amount`); + } +} + +function positive(value: MoneyValue, field: string): Decimal { + const result = decimal(value, field); + if (result.lte(0)) { + throw new ConflictException(`${field} must be positive`); + } + return result; +} + +function nonNegative(value: MoneyValue, field: string): Decimal { + const result = decimal(value, field); + if (result.lt(0)) { + throw new ConflictException(`${field} must be non-negative`); + } + return result; +} + +function currency(value: Decimal, exponent = 2): Decimal { + return value.toDecimalPlaces(exponent); +} + +function selectedTotal( + source: BookingRequestPriceSource, + input: ResolveAcceptedTotalInput, +): MoneyValue { + switch (source) { + case 'submitted': + if (input.submittedTotal == null) { + throw new ConflictException('Submitted total is required'); + } + return input.submittedTotal; + case 'current': + if (input.currentTotal == null) { + throw new ConflictException('Current total is required'); + } + return input.currentTotal; + case 'custom': + if (input.customTotal == null) { + throw new ConflictException('Custom total is required'); + } + return input.customTotal; + } +} + +export function resolveAcceptedTotal( + input: ResolveAcceptedTotalInput, +): AcceptedTotalResolution { + const selected = selectedTotal(input.source, input); + // Submitted/current totals come from an authoritative quote. Custom totals + // are staff-entered and therefore require the explicit positive-money rule. + const total = input.source === 'custom' + ? positive(selected, 'Custom accepted total') + : currency(decimal(selected, `${input.source} accepted total`)); + + if (input.source === 'custom') { + const reason = input.customReason?.trim(); + if (!reason) { + throw new ConflictException('A reason is required for a custom accepted price'); + } + return { source: input.source, total: currency(total), customReason: reason }; + } + + return { source: input.source, total: currency(total), customReason: undefined }; +} + +export function resolveInstallmentAmount( + input: ResolveInstallmentAmountInput, +): Decimal { + const hasFixed = input.fixedAmount != null; + const hasPercentage = input.percentage != null; + if (hasFixed === hasPercentage) { + throw new ConflictException('An installment requires exactly one fixed amount or percentage'); + } + + let result: Decimal; + if (hasFixed) { + result = positive(input.fixedAmount!, 'Fixed installment amount'); + } else { + const percentage = positive(input.percentage!, 'Installment percentage'); + if (input.total == null && input.acceptedTotal == null) { + throw new ConflictException('Accepted total is required for a percentage installment'); + } + const total = positive(input.total ?? input.acceptedTotal!, 'Accepted total'); + result = total.times(percentage).div(100); + } + + result = currency(result, input.currencyExponent ?? 2); + if (result.lte(0)) { + throw new ConflictException('Installment amount must be positive'); + } + + if (input.allocatedAmount != null) { + const allocated = positive(input.allocatedAmount, 'Allocated amount'); + if (allocated.gt(result)) { + throw new ConflictException('Allocated amount cannot exceed the installment total'); + } + } + + return result; +} + +/** Validate a payment allocation against both the movement and installment. */ +export function assertAllocationAmount(input: AllocationAmountInput): void { + const amount = positive(input.amount, 'Allocation amount'); + const movement = positive(input.movementAmount, 'Payment movement amount'); + const installment = positive(input.installmentAmount, 'Installment amount'); + if (amount.gt(movement)) { + throw new ConflictException('Allocation amount cannot exceed the payment movement amount'); + } + if (amount.gt(installment)) { + throw new ConflictException('Allocation amount cannot exceed the installment amount'); + } + + const alreadyMovement = input.alreadyAllocatedMovementAmount == null + ? new Decimal(0) + : nonNegative(input.alreadyAllocatedMovementAmount, 'Already allocated movement amount'); + const alreadyInstallment = input.alreadyAllocatedInstallmentAmount == null + ? new Decimal(0) + : nonNegative(input.alreadyAllocatedInstallmentAmount, 'Already allocated installment amount'); + if (input.remainingMovementAmount != null && amount.gt(nonNegative(input.remainingMovementAmount, 'Remaining movement amount'))) { + throw new ConflictException('Allocation amount cannot exceed the remaining payment movement amount'); + } + if (input.remainingInstallmentAmount != null && amount.gt(nonNegative(input.remainingInstallmentAmount, 'Remaining installment amount'))) { + throw new ConflictException('Allocation amount cannot exceed the remaining installment amount'); + } + if (alreadyMovement.plus(amount).gt(movement)) { + throw new ConflictException('Cumulative allocation cannot exceed the payment movement amount'); + } + if (alreadyInstallment.plus(amount).gt(installment)) { + throw new ConflictException('Cumulative allocation cannot exceed the installment amount'); + } +} + +function movementKey(movement: CapturedMovement): string | undefined { + return movement.paymentId ?? movement.id; +} + +function isCapturedMovement(movement: CapturedMovement): boolean { + if (movement.status && !['captured', 'settled', 'partially_refunded', 'refunded'].includes(movement.status)) { + return false; + } + // Refund/correction child rows are not independent captured money. + if (movement.type === 'refund' || movement.type === 'external_return') return false; + const capturedAmount = decimal(movement.amount, 'Captured payment amount'); + if (capturedAmount.lt(0)) { + throw new ConflictException('Captured payment amount must not be negative'); + } + const netAmount = decimal(movement.netAmount ?? movement.amount, 'Captured payment amount'); + if (netAmount.lt(0)) { + throw new ConflictException('Captured payment net amount must not be negative'); + } + return netAmount.gt(0); +} + +/** + * Denial may proceed only after every positive captured movement is returned or + * retained. Resolutions are grouped by payment/movement ID when supplied. + */ +export function assertDenialMoneyResolved( + movements: readonly CapturedMovement[], + resolutions: readonly DenialResolution[], +): void { + const captured = movements.filter(isCapturedMovement); + const movementKeys = new Set(movements.map(movementKey).filter((key): key is string => key != null)); + const capturedKeys = new Set(captured.map(movementKey).filter((key): key is string => key != null)); + + const sums = new Map(); + let unkeyed = new Decimal(0); + for (const resolution of resolutions) { + if (resolution.type === 'retained' && !resolution.reason?.trim()) { + throw new ConflictException('A reason is required for retained money'); + } + const amount = positive(resolution.amount, `${resolution.type} resolution`); + const key = resolution.paymentId ?? resolution.movementId; + if (key != null && !movementKeys.has(key)) { + throw new ConflictException(`Resolution references unknown captured movement '${key}'`); + } + // A zero-net movement has already been fully returned. Its historical + // resolution is valid, but it is not part of the remaining denial check. + if (key != null && !capturedKeys.has(key)) continue; + if (key == null) { + unkeyed = unkeyed.plus(amount); + } else { + sums.set(key, (sums.get(key) ?? new Decimal(0)).plus(amount)); + } + } + + if (captured.length === 0) { + const hasRelevantResolution = resolutions.some((resolution) => { + const key = resolution.paymentId ?? resolution.movementId; + return key == null || capturedKeys.has(key); + }); + if (hasRelevantResolution) { + throw new ConflictException('Resolution references no captured movement'); + } + return; + } + + for (const movement of captured) { + const expected = currency(decimal(movement.netAmount ?? movement.amount, 'movement')); + const key = movementKey(movement); + const resolved = key != null + ? (sums.get(key) ?? new Decimal(0)) + : unkeyed; + if (!resolved.eq(expected)) { + const label = key ?? 'unidentified movement'; + throw new ConflictException( + `Captured movement '${label}' has unresolved money: expected ${expected.toFixed(2)}, resolved ${resolved.toFixed(2)}`, + ); + } + } +} diff --git a/apps/api/src/modules/booking-request/booking-request-payment-consequence.ts b/apps/api/src/modules/booking-request/booking-request-payment-consequence.ts new file mode 100644 index 00000000..0c181333 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-payment-consequence.ts @@ -0,0 +1,163 @@ +import { + auditLogs, + bookingRequestConsequences, + bookingRequestEmailDeliveries, + bookingRequests, +} from './booking-request-db.js'; +import type { BookingRequestConsequenceKind } from './booking-request-db.js'; +import type { WebhookEvent } from '@telivityhaip/shared'; +import { and, eq } from 'drizzle-orm'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { + failedBookingRequestPaymentEmail, + paymentReceivedBookingRequestEmail, + refundedBookingRequestPaymentEmail, +} from './booking-request-email.templates'; + +export type BookingRequestFinancialEvent = + Extract; + +const kindPrefix: Record = { + 'payment.received': 'payment_received', + 'payment.failed': 'payment_failed', + 'payment.refunded': 'payment_refunded', +}; +type FinancialConsequenceExecutor = Pick; + +/** + * Atomically persists a replayable financial webhook consequence. + * `logicalId` is the durable movement/payment/resolution UUID, so an API retry + * or Stripe delivery replay repairs a missing outbox row without duplicating it. + */ +export async function ensureBookingRequestFinancialConsequence( + tx: FinancialConsequenceExecutor, + input: { + event: BookingRequestFinancialEvent; + logicalId: string; + propertyId: string; + bookingRequestId: string; + entityType: string; + entityId: string; + data: Record; + }, +): Promise { + const compactLogicalId = input.logicalId.replaceAll('-', ''); + const kind = `${kindPrefix[input.event]}:${compactLogicalId}`.slice( + 0, + 50, + ) as BookingRequestConsequenceKind; + const payload = { + event: input.event, + entityType: input.entityType, + entityId: input.entityId, + propertyId: input.propertyId, + data: structuredClone(input.data), + timestamp: new Date().toISOString(), + }; + await tx + .insert(bookingRequestConsequences) + .values({ + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + kind, + payload, + status: 'pending', + attempts: 0, + }) + .onConflictDoNothing(); + await ensureFinancialEmail(tx, input); +} + +async function ensureFinancialEmail( + tx: FinancialConsequenceExecutor, + input: Parameters[1], +): Promise { + const amount = firstString( + input.data['amount'], + input.data['refundAmount'], + input.data['returnAmount'], + ); + const currencyCode = firstString(input.data['currencyCode']); + if (!amount || !currencyCode) return; + + const requests = await tx + .select() + .from(bookingRequests) + .where(and( + eq(bookingRequests.id, input.bookingRequestId), + eq(bookingRequests.propertyId, input.propertyId), + )); + const request = requests.find((row: typeof bookingRequests.$inferSelect) => + row.id === input.bookingRequestId && row.propertyId === input.propertyId); + if (!request) return; + + let kind: typeof bookingRequestEmailDeliveries.$inferInsert.kind; + let logicalPrefix: string; + let content: { subject: string; bodyText: string }; + if (input.event === 'payment.received') { + kind = 'payment'; + logicalPrefix = 'payment'; + content = paymentReceivedBookingRequestEmail({ + guestFirstName: request.guestFirstName, + amount, + currencyCode, + source: input.data['source'] === 'external' ? 'external' : 'saved_card', + }); + } else if (input.event === 'payment.refunded') { + kind = 'refund'; + logicalPrefix = 'refund'; + content = refundedBookingRequestPaymentEmail({ + guestFirstName: request.guestFirstName, + amount, + currencyCode, + source: input.data['source'] === 'external_return' ? 'external_return' : 'refund', + }); + } else { + kind = 'failure'; + logicalPrefix = 'failure'; + content = failedBookingRequestPaymentEmail({ + guestFirstName: request.guestFirstName, + amount, + currencyCode, + operation: input.data['type'] === 'refund' ? 'refund' : 'charge', + }); + } + + const queuedAt = new Date(); + const [created] = await tx + .insert(bookingRequestEmailDeliveries) + .values({ + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + logicalKey: `${logicalPrefix}:${input.logicalId}`, + kind, + status: 'pending', + recipient: request.guestEmail, + subject: content.subject, + bodyText: content.bodyText, + attempts: 0, + automaticAttempts: 0, + nextAttemptAt: queuedAt, + }) + .onConflictDoNothing() + .returning({ id: bookingRequestEmailDeliveries.id }); + if (created) { + await tx.insert(auditLogs).values({ + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + action: 'create', + entityType: 'booking_request_email_delivery', + entityId: created.id, + description: `Booking request ${kind} email queued`, + newValue: { + bookingRequestId: input.bookingRequestId, + kind, + status: 'pending', + }, + }); + } +} + +function firstString(...values: unknown[]): string | undefined { + return values.find((value): value is string => typeof value === 'string' && value.length > 0); +} diff --git a/apps/api/src/modules/booking-request/booking-request-payment-ledger.ts b/apps/api/src/modules/booking-request/booking-request-payment-ledger.ts new file mode 100644 index 00000000..aaa5ce70 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-payment-ledger.ts @@ -0,0 +1,116 @@ +import Decimal from 'decimal.js'; +import { remainingCapturedAmount } from '../payment/payment-ledger'; + +export const BOOKING_REQUEST_PARENT_PAYMENT_STATUSES = new Set([ + 'captured', + 'settled', + 'partially_refunded', + 'refunded', +]); + +export type BookingRequestLedgerMovement = { + id: string; + originalPaymentId?: string | null; + status: string; + amount: string; +}; + +export type BookingRequestLedgerAllocation = { + paymentId: string; + amount: string; +}; + +export type BookingRequestLedgerResolution = { + paymentId: string; + type?: string | null; + status?: string | null; + amount: string; + movementId?: string | null; +}; + +export type BookingRequestPaymentLedgerSummary = { + netCaptured: Decimal; + allocated: Decimal; + reservedResolution: Decimal; + completedResolution: Decimal; + availableToAllocate: Decimal; + availableToResolve: Decimal; + unresolved: Decimal; + returned: Decimal; + retained: Decimal; +}; + +const ZERO_SUMMARY = (): BookingRequestPaymentLedgerSummary => ({ + netCaptured: new Decimal(0), + allocated: new Decimal(0), + reservedResolution: new Decimal(0), + completedResolution: new Decimal(0), + availableToAllocate: new Decimal(0), + availableToResolve: new Decimal(0), + unresolved: new Decimal(0), + returned: new Decimal(0), + retained: new Decimal(0), +}); + +/** + * Canonical per-parent Booking Request money view. + * + * Captured negative child movements are the ledger fact and reduce net once. + * A completed resolution backed by one of those movements is provenance only; + * completed movement-less legacy/retained resolutions consume the remaining + * unresolved balance. Pending durable claims reserve, but do not resolve, it. + */ +export function summarizeBookingRequestPaymentLedger( + payment: BookingRequestLedgerMovement, + movements: readonly BookingRequestLedgerMovement[], + allocations: readonly BookingRequestLedgerAllocation[], + resolutions: readonly BookingRequestLedgerResolution[], +): BookingRequestPaymentLedgerSummary { + if ( + payment.originalPaymentId != null + || !BOOKING_REQUEST_PARENT_PAYMENT_STATUSES.has(payment.status) + || new Decimal(payment.amount).lte(0) + ) return ZERO_SUMMARY(); + + const children = movements.filter((row) => + row.originalPaymentId === payment.id + && row.status === 'captured' + && new Decimal(row.amount).lt(0)); + const childIds = new Set(children.map((row) => row.id)); + const netCaptured = Decimal.max(remainingCapturedAmount(payment.amount, children), 0); + const allocated = allocations + .filter((row) => row.paymentId === payment.id) + .reduce((sum, row) => sum.plus(row.amount), new Decimal(0)); + const paymentResolutions = resolutions.filter((row) => row.paymentId === payment.id); + const reservedResolution = paymentResolutions + .filter((row) => row.status === 'pending') + .reduce((sum, row) => sum.plus(row.amount), new Decimal(0)); + const completedWithoutMovement = paymentResolutions + .filter((row) => row.status == null || row.status === 'completed') + .filter((row) => !row.movementId || !childIds.has(row.movementId)); + const completedResolution = completedWithoutMovement + .reduce((sum, row) => sum.plus(row.amount), new Decimal(0)); + const retained = completedWithoutMovement + .filter((row) => row.type === 'retained') + .reduce((sum, row) => sum.plus(row.amount), new Decimal(0)); + const returned = new Decimal(payment.amount) + .minus(netCaptured) + .plus(completedWithoutMovement + .filter((row) => row.type !== 'retained') + .reduce((sum, row) => sum.plus(row.amount), new Decimal(0))); + const unresolved = Decimal.max(netCaptured.minus(completedResolution), 0); + const availableToResolve = Decimal.max(unresolved.minus(reservedResolution), 0); + const availableToAllocate = Decimal.max(availableToResolve.minus(allocated), 0); + + return { + netCaptured, + allocated, + reservedResolution, + completedResolution, + availableToAllocate, + availableToResolve, + unresolved, + returned, + retained, + }; +} diff --git a/apps/api/src/modules/booking-request/booking-request-payment.db.spec.ts b/apps/api/src/modules/booking-request/booking-request-payment.db.spec.ts new file mode 100644 index 00000000..3e7af57d --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-payment.db.spec.ts @@ -0,0 +1,864 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { eq } from 'drizzle-orm'; +import { readFileSync } from 'node:fs'; +import { + auditLogs, + bookingRequestConsequences, + bookingRequestEmailDeliveries, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + bookingRequestPaymentResolutions, + bookingRequests, + payments, + properties, + ratePlans, + roomTypes, +} from './booking-request-db.js'; +import { describe, expect, it, vi, beforeAll, afterAll } from 'vitest'; +import { BookingRequestService } from './booking-request.service'; +import { BookingRequestPaymentService } from './booking-request-payment.service'; + +const databaseUrl = process.env['PAYMENT_DB_TEST_URL']; +const describeDatabase = databaseUrl ? describe : describe.skip; +const financialRecoveryMigration = readFileSync( + new URL('../../../../../packages/booking-requests/src/database/migrations/0025_booking_request_financial_recovery.sql', import.meta.url), + 'utf8', +); +const bookingRequestAuditRelationshipMigration = readFileSync( + new URL('../../../../../packages/booking-requests/src/database/migrations/0029_booking_request_audit_relationship.sql', import.meta.url), + 'utf8', +); + +function makeAuditService(database: unknown): BookingRequestService { + return new BookingRequestService( + database as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); +} + +describeDatabase('Booking Request PostgreSQL money and audit contract', () => { + const propertyId = '71000000-0000-4000-a000-000000000001'; + const roomTypeId = '71000000-0000-4000-a000-000000000002'; + const ratePlanId = '71000000-0000-4000-a000-000000000003'; + const requestId = '71000000-0000-4000-a000-000000000004'; + const auditRequestId = '71000000-0000-4000-a000-000000000020'; + const paymentId = '71000000-0000-4000-a000-000000000005'; + const installmentId = '71000000-0000-4000-a000-000000000006'; + const secondPaymentId = '71000000-0000-4000-a000-000000000007'; + const secondMovementId = '71000000-0000-4000-a000-000000000008'; + const repairPaymentId = '71000000-0000-4000-a000-000000000009'; + const repairMovementId = '71000000-0000-4000-a000-000000000010'; + const repairInstallmentId = '71000000-0000-4000-a000-000000000011'; + const repairAllocationId = '71000000-0000-4000-a000-000000000012'; + const unchangedPaymentId = '71000000-0000-4000-a000-000000000013'; + const unchangedInstallmentId = '71000000-0000-4000-a000-000000000014'; + const unchangedAllocationId = '71000000-0000-4000-a000-000000000015'; + const deletedPaymentId = '71000000-0000-4000-a000-000000000016'; + const deletedMovementId = '71000000-0000-4000-a000-000000000017'; + const deletedInstallmentId = '71000000-0000-4000-a000-000000000018'; + const deletedAllocationId = '71000000-0000-4000-a000-000000000019'; + const otherPropertyId = '72000000-0000-4000-a000-000000000001'; + const otherRoomTypeId = '72000000-0000-4000-a000-000000000002'; + const otherRatePlanId = '72000000-0000-4000-a000-000000000003'; + const otherRequestId = '72000000-0000-4000-a000-000000000004'; + let client: ReturnType; + let db: ReturnType; + + beforeAll(async () => { + client = postgres(databaseUrl!, { max: 10 }); + db = drizzle(client); + await db.insert(properties).values({ + id: propertyId, + name: 'Task 7 payment test', + code: 'TASK7PAY', + countryCode: 'ES', + timezone: 'Europe/Madrid', + currencyCode: 'EUR', + totalRooms: 1, + }); + await db.insert(roomTypes).values({ + id: roomTypeId, + propertyId, + name: 'Test room', + code: 'TEST', + maxOccupancy: 2, + defaultOccupancy: 2, + }); + await db.insert(ratePlans).values({ + id: ratePlanId, + propertyId, + roomTypeId, + name: 'Test rate', + code: 'TEST', + type: 'bar', + baseAmount: '100.00', + currencyCode: 'EUR', + }); + await db.insert(bookingRequests).values({ + id: requestId, + propertyId, + submissionIdempotencyKey: 'task-7-db-concurrency', + submissionFingerprint: 'a'.repeat(64), + arrivalDate: '2026-09-01', + departureDate: '2026-09-02', + roomTypeId, + ratePlanId, + guestFirstName: 'Task', + guestLastName: 'Seven', + guestEmail: 'task7@example.com', + submittedQuoteSnapshot: { grandTotal: '100.00' }, + submittedTotal: '100.00', + currencyCode: 'EUR', + }); + await db.insert(bookingRequests).values({ + id: auditRequestId, + propertyId, + submissionIdempotencyKey: 'task-11-audit-db-contract', + submissionFingerprint: 'c'.repeat(64), + arrivalDate: '2026-09-03', + departureDate: '2026-09-04', + roomTypeId, + ratePlanId, + guestFirstName: 'Audit', + guestLastName: 'Cursor', + guestEmail: 'audit-cursor@example.com', + submittedQuoteSnapshot: { grandTotal: '100.00' }, + submittedTotal: '100.00', + currencyCode: 'EUR', + }); + await db.insert(payments).values({ + id: paymentId, + propertyId, + bookingRequestId: requestId, + idempotencyKey: 'booking-request-charge:task-7-db-parent', + method: 'credit_card', + status: 'captured', + amount: '100.00', + currencyCode: 'EUR', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_task_7_db', + processedAt: new Date(), + }); + await db.insert(properties).values({ + id: otherPropertyId, + name: 'Task 7 other property', + code: 'TASK7OTHER', + countryCode: 'ES', + timezone: 'Europe/Madrid', + currencyCode: 'EUR', + totalRooms: 1, + }); + await db.insert(roomTypes).values({ + id: otherRoomTypeId, + propertyId: otherPropertyId, + name: 'Other room', + code: 'OTHER', + maxOccupancy: 2, + defaultOccupancy: 2, + }); + await db.insert(ratePlans).values({ + id: otherRatePlanId, + propertyId: otherPropertyId, + roomTypeId: otherRoomTypeId, + name: 'Other rate', + code: 'OTHER', + type: 'bar', + baseAmount: '100.00', + currencyCode: 'EUR', + }); + await db.insert(bookingRequests).values({ + id: otherRequestId, + propertyId: otherPropertyId, + submissionIdempotencyKey: 'task-7-other-request', + submissionFingerprint: 'b'.repeat(64), + arrivalDate: '2026-09-01', + departureDate: '2026-09-02', + roomTypeId: otherRoomTypeId, + ratePlanId: otherRatePlanId, + guestFirstName: 'Other', + guestLastName: 'Property', + guestEmail: 'other@example.com', + submittedQuoteSnapshot: { grandTotal: '100.00' }, + submittedTotal: '100.00', + currencyCode: 'EUR', + }); + }); + + afterAll(async () => { + if (!client) return; + await db.delete(bookingRequestPaymentAllocations) + .where(eq(bookingRequestPaymentAllocations.bookingRequestId, requestId)); + await db.delete(bookingRequestConsequences) + .where(eq(bookingRequestConsequences.bookingRequestId, requestId)); + await db.delete(bookingRequestEmailDeliveries) + .where(eq(bookingRequestEmailDeliveries.bookingRequestId, requestId)); + await db.delete(auditLogs).where(eq(auditLogs.propertyId, propertyId)); + await db.delete(bookingRequestPaymentResolutions) + .where(eq(bookingRequestPaymentResolutions.bookingRequestId, requestId)); + await db.delete(payments).where(eq(payments.bookingRequestId, requestId)); + await db.delete(bookingRequestInstallments) + .where(eq(bookingRequestInstallments.bookingRequestId, requestId)); + await db.delete(bookingRequests).where(eq(bookingRequests.id, requestId)); + await db.delete(bookingRequests).where(eq(bookingRequests.id, auditRequestId)); + await db.delete(ratePlans).where(eq(ratePlans.id, ratePlanId)); + await db.delete(roomTypes).where(eq(roomTypes.id, roomTypeId)); + await db.delete(properties).where(eq(properties.id, propertyId)); + await db.delete(bookingRequests).where(eq(bookingRequests.id, otherRequestId)); + await db.delete(ratePlans).where(eq(ratePlans.id, otherRatePlanId)); + await db.delete(roomTypes).where(eq(roomTypes.id, otherRoomTypeId)); + await db.delete(properties).where(eq(properties.id, otherPropertyId)); + await client.end(); + }); + + it('paginates audit rows by their durable sequence and rejects invalid cursors', async () => { + const auditIds = [ + '71000000-0000-4000-a000-000000000021', + '71000000-0000-4000-a000-000000000022', + '71000000-0000-4000-a000-000000000023', + ]; + await db.insert(auditLogs).values(auditIds.map((id) => ({ + id, + propertyId, + bookingRequestId: auditRequestId, + action: 'update', + entityType: 'booking_request', + entityId: auditRequestId, + newValue: { status: 'pending' }, + }))); + const insertedTimeline = await db.select({ + id: auditLogs.id, + timelineSequence: auditLogs.timelineSequence, + }).from(auditLogs).where(eq(auditLogs.bookingRequestId, auditRequestId)); + const sequenceById = new Map(insertedTimeline.map((row) => [ + row.id, + row.timelineSequence.toString(), + ])); + const service = makeAuditService(db); + + const first = await service.auditHistory(auditRequestId, propertyId, { limit: 2 }); + const decoded = JSON.parse(Buffer.from(first.nextCursor!, 'base64url').toString('utf8')); + const second = await service.auditHistory(auditRequestId, propertyId, { + limit: 2, + cursor: first.nextCursor!, + }); + + expect(first.data.map((row) => row.id)).toEqual([auditIds[2], auditIds[1]]); + expect(decoded).toMatchObject({ + timelineSequence: sequenceById.get(auditIds[1]), + }); + expect(Object.keys(decoded)).toEqual(['timelineSequence']); + expect(decoded).not.toHaveProperty('occurredAt'); + expect(second.data[0]?.id).toBe(auditIds[0]); + expect(new Set([...first.data, ...second.data].map((row) => row.id)).size) + .toBe(first.data.length + second.data.length); + + const invalidCursor = Buffer.from(JSON.stringify({ + timelineSequence: 'not-a-sequence', + })).toString('base64url'); + await expect(service.auditHistory(auditRequestId, propertyId, { + limit: 2, + cursor: invalidCursor, + })).rejects.toMatchObject({ status: 400 }); + }); + + it('backfills deleted-child audit tombstones from one unambiguous direct relationship', async () => { + const tombstoneInstallmentId = '71000000-0000-4000-a000-000000000024'; + const tombstoneAllocationId = '71000000-0000-4000-a000-000000000025'; + const createAuditId = '71000000-0000-4000-a000-000000000026'; + const deleteAuditId = '71000000-0000-4000-a000-000000000027'; + const conflictEntityId = '71000000-0000-4000-a000-000000000028'; + const conflictAuditId = '71000000-0000-4000-a000-000000000029'; + await db.insert(bookingRequestInstallments).values({ + id: tombstoneInstallmentId, + propertyId, + bookingRequestId: requestId, + label: 'Deleted audit fixture', + fixedAmount: '1.00', + resolvedAmount: '1.00', + dueMilestone: 'manual', + sortOrder: 99, + }); + await db.insert(bookingRequestPaymentAllocations).values({ + id: tombstoneAllocationId, + propertyId, + bookingRequestId: requestId, + paymentId, + installmentId: tombstoneInstallmentId, + amount: '1.00', + }); + await db.insert(auditLogs).values({ + id: createAuditId, + propertyId, + bookingRequestId: requestId, + action: 'create', + entityType: 'booking_request_payment_allocation', + entityId: tombstoneAllocationId, + newValue: { amount: '1.00' }, + occurredAt: new Date('2099-01-01T00:00:00.002Z'), + }); + await db.delete(bookingRequestPaymentAllocations) + .where(eq(bookingRequestPaymentAllocations.id, tombstoneAllocationId)); + await db.insert(auditLogs).values({ + id: deleteAuditId, + propertyId, + bookingRequestId: null, + action: 'delete', + entityType: 'booking_request_payment_allocation', + entityId: tombstoneAllocationId, + previousValue: { amount: '1.00' }, + occurredAt: new Date('2099-01-01T00:00:00.001Z'), + }); + await db.insert(auditLogs).values([{ + id: '71000000-0000-4000-a000-000000000030', + propertyId, + bookingRequestId: requestId, + action: 'create', + entityType: 'booking_request_payment_allocation', + entityId: conflictEntityId, + }, { + id: '71000000-0000-4000-a000-000000000031', + propertyId, + bookingRequestId: auditRequestId, + action: 'update', + entityType: 'booking_request_payment_allocation', + entityId: conflictEntityId, + }, { + id: conflictAuditId, + propertyId, + bookingRequestId: null, + action: 'delete', + entityType: 'booking_request_payment_allocation', + entityId: conflictEntityId, + }]); + + await client.unsafe(bookingRequestAuditRelationshipMigration); + await client.unsafe(bookingRequestAuditRelationshipMigration); + + const tombstones = await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, tombstoneAllocationId)); + const [conflict] = await db.select().from(auditLogs) + .where(eq(auditLogs.id, conflictAuditId)); + expect(tombstones).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: createAuditId, bookingRequestId: requestId }), + expect.objectContaining({ id: deleteAuditId, bookingRequestId: requestId }), + ])); + expect(conflict.bookingRequestId).toBeNull(); + + const service = makeAuditService(db); + const history = await service.auditHistory(requestId, propertyId, { limit: 100 }); + expect(history.data.map((row) => row.id)).toEqual(expect.arrayContaining([ + createAuditId, + deleteAuditId, + ])); + }); + + it('serializes different-key claims so pending capacity cannot be over-reserved', async () => { + let release!: (value: { success: true; transactionId: string }) => void; + const refundGateway = { + refund: vi.fn(() => new Promise((resolve) => { release = resolve; })), + }; + const service = new (BookingRequestPaymentService as any)( + db, + { charge: vi.fn() }, + { recalculateBalance: vi.fn() }, + refundGateway, + ) as BookingRequestPaymentService; + + const first = service.refund( + requestId, + paymentId, + propertyId, + { amount: '50.00', idempotencyKey: 'db-first-half' }, + ); + await vi.waitFor(() => expect(refundGateway.refund).toHaveBeenCalledTimes(1)); + + await expect(service.refund( + requestId, + paymentId, + propertyId, + { amount: '50.01', idempotencyKey: 'db-overreserve' }, + )).rejects.toThrow(/remaining captured amount/i); + + release({ success: true, transactionId: 're_task_7_db' }); + await expect(first).resolves.toMatchObject({ + movement: { amount: '-50.00' }, + resolution: { status: 'completed' }, + }); + const rows = await db.select().from(bookingRequestPaymentResolutions) + .where(eq(bookingRequestPaymentResolutions.bookingRequestId, requestId)); + expect(rows).toEqual([ + expect.objectContaining({ amount: '50.00', status: 'completed' }), + ]); + }); + + it('enforces positive parent, installment shape, and retained-reason checks', async () => { + await expect(db.insert(payments).values({ + propertyId, + bookingRequestId: requestId, + method: 'cash', + status: 'captured', + amount: '0.00', + currencyCode: 'EUR', + })).rejects.toMatchObject({ + constraint_name: 'payments_booking_request_parent_positive_check', + }); + + await expect(db.insert(bookingRequestInstallments).values({ + propertyId, + bookingRequestId: requestId, + label: 'Invalid shape', + fixedAmount: '10.00', + percentage: '10.00', + resolvedAmount: '10.00', + dueMilestone: 'manual', + })).rejects.toMatchObject({ + constraint_name: 'booking_request_installments_amount_kind_check', + }); + + await expect(db.insert(bookingRequestPaymentResolutions).values({ + propertyId, + bookingRequestId: requestId, + paymentId, + type: 'retained', + status: 'completed', + amount: '1.00', + reason: ' ', + resolvedAt: new Date(), + })).rejects.toMatchObject({ + constraint_name: 'booking_request_payment_resolutions_retained_reason_check', + }); + }); + + it('enforces request/property ownership, refund child shape, and resolution lifecycle', async () => { + await expect(db.insert(payments).values({ + propertyId, + bookingRequestId: otherRequestId, + method: 'cash', + status: 'captured', + amount: '10.00', + currencyCode: 'EUR', + })).rejects.toMatchObject({ constraint_name: 'payments_booking_request_fkey' }); + + await expect(db.insert(bookingRequestInstallments).values({ + propertyId, + bookingRequestId: otherRequestId, + label: 'Wrong owner', + fixedAmount: '10.00', + resolvedAmount: '10.00', + dueMilestone: 'manual', + })).rejects.toMatchObject({ constraint_name: 'booking_request_installments_request_fkey' }); + + await expect(db.insert(payments).values({ + propertyId: otherPropertyId, + bookingRequestId: otherRequestId, + originalPaymentId: paymentId, + method: 'credit_card', + status: 'captured', + amount: '-10.00', + currencyCode: 'EUR', + })).rejects.toMatchObject({ constraint_name: 'payments_booking_request_parent_fkey' }); + + await expect(db.insert(payments).values({ + propertyId, + bookingRequestId: requestId, + originalPaymentId: paymentId, + method: 'credit_card', + status: 'pending', + amount: '10.00', + currencyCode: 'EUR', + })).rejects.toMatchObject({ constraint_name: 'payments_booking_request_child_shape_check' }); + + await expect(db.insert(bookingRequestPaymentResolutions).values({ + propertyId, + bookingRequestId: requestId, + paymentId, + type: 'refund', + status: 'completed', + amount: '1.00', + resolvedAt: new Date(), + })).rejects.toMatchObject({ + constraint_name: 'booking_request_payment_resolutions_lifecycle_check', + }); + + await expect(db.insert(bookingRequestPaymentResolutions).values({ + propertyId, + bookingRequestId: requestId, + paymentId, + type: 'retained', + status: 'pending', + amount: '1.00', + reason: 'Pending retention is invalid', + })).rejects.toMatchObject({ + constraint_name: 'booking_request_payment_resolutions_lifecycle_check', + }); + }); + + it('rejects cross-scope consequence/allocation rows and movements from another parent', async () => { + await expect(db.insert(bookingRequestConsequences).values({ + propertyId, + bookingRequestId: otherRequestId, + kind: 'payment_received:cross-scope', + payload: {}, + })).rejects.toMatchObject({ + constraint_name: 'booking_request_consequences_request_fkey', + }); + + await db.insert(bookingRequestInstallments).values({ + id: installmentId, + propertyId, + bookingRequestId: requestId, + label: 'Ownership fixture', + fixedAmount: '10.00', + resolvedAmount: '10.00', + dueMilestone: 'manual', + }); + await expect(db.insert(bookingRequestPaymentAllocations).values({ + propertyId: otherPropertyId, + bookingRequestId: otherRequestId, + paymentId, + installmentId, + amount: '1.00', + })).rejects.toMatchObject({ + constraint_name: 'booking_request_payment_allocations_payment_fkey', + }); + + await db.insert(payments).values({ + id: secondPaymentId, + propertyId, + bookingRequestId: requestId, + method: 'cash', + status: 'captured', + amount: '20.00', + currencyCode: 'EUR', + processedAt: new Date(), + }); + await db.insert(payments).values({ + id: secondMovementId, + propertyId, + bookingRequestId: requestId, + originalPaymentId: secondPaymentId, + method: 'cash', + status: 'captured', + amount: '-5.00', + currencyCode: 'EUR', + processedAt: new Date(), + }); + await expect(db.insert(bookingRequestPaymentResolutions).values({ + propertyId, + bookingRequestId: requestId, + paymentId, + type: 'external_return', + status: 'completed', + amount: '5.00', + movementId: secondMovementId, + reason: 'Movement belongs to a different parent', + resolvedAt: new Date(), + })).rejects.toMatchObject({ + constraint_name: 'booking_request_payment_resolutions_parent_movement_fkey', + }); + }); + + it('audits allocation repair once and leaves repeat/unchanged timestamps untouched', async () => { + const originalTimestamp = new Date('2026-08-24T10:00:00.000Z'); + await db.insert(payments).values([{ + id: repairPaymentId, + propertyId, + bookingRequestId: requestId, + method: 'cash', + status: 'captured', + amount: '100.00', + currencyCode: 'EUR', + }, { + id: unchangedPaymentId, + propertyId, + bookingRequestId: requestId, + method: 'cash', + status: 'captured', + amount: '10.00', + currencyCode: 'EUR', + }, { + id: deletedPaymentId, + propertyId, + bookingRequestId: requestId, + method: 'cash', + status: 'captured', + amount: '25.00', + currencyCode: 'EUR', + }]); + await db.insert(payments).values([{ + id: repairMovementId, + propertyId, + bookingRequestId: requestId, + originalPaymentId: repairPaymentId, + method: 'cash', + status: 'captured', + amount: '-40.00', + currencyCode: 'EUR', + }, { + id: deletedMovementId, + propertyId, + bookingRequestId: requestId, + originalPaymentId: deletedPaymentId, + method: 'cash', + status: 'captured', + amount: '-25.00', + currencyCode: 'EUR', + }]); + await db.insert(bookingRequestInstallments).values([{ + id: repairInstallmentId, + propertyId, + bookingRequestId: requestId, + label: 'Stale allocation', + fixedAmount: '100.00', + resolvedAmount: '100.00', + allocatedAmount: '100.00', + status: 'paid', + dueMilestone: 'manual', + updatedAt: originalTimestamp, + }, { + id: unchangedInstallmentId, + propertyId, + bookingRequestId: requestId, + label: 'Already correct', + fixedAmount: '10.00', + resolvedAmount: '10.00', + allocatedAmount: '10.00', + status: 'paid', + dueMilestone: 'manual', + updatedAt: originalTimestamp, + }, { + id: deletedInstallmentId, + propertyId, + bookingRequestId: requestId, + label: 'Fully returned allocation', + fixedAmount: '25.00', + resolvedAmount: '25.00', + allocatedAmount: '25.00', + status: 'paid', + dueMilestone: 'manual', + updatedAt: originalTimestamp, + }]); + await db.insert(bookingRequestPaymentAllocations).values([{ + id: repairAllocationId, + propertyId, + bookingRequestId: requestId, + paymentId: repairPaymentId, + installmentId: repairInstallmentId, + amount: '100.00', + }, { + id: unchangedAllocationId, + propertyId, + bookingRequestId: requestId, + paymentId: unchangedPaymentId, + installmentId: unchangedInstallmentId, + amount: '10.00', + }, { + id: deletedAllocationId, + propertyId, + bookingRequestId: requestId, + paymentId: deletedPaymentId, + installmentId: deletedInstallmentId, + amount: '25.00', + }]); + + await client.unsafe(financialRecoveryMigration); + const [changedAfterFirst] = await db.select().from(bookingRequestInstallments) + .where(eq(bookingRequestInstallments.id, repairInstallmentId)); + const [unchangedAfterFirst] = await db.select().from(bookingRequestInstallments) + .where(eq(bookingRequestInstallments.id, unchangedInstallmentId)); + const [allocationAfterFirst] = await db.select().from(bookingRequestPaymentAllocations) + .where(eq(bookingRequestPaymentAllocations.id, repairAllocationId)); + const deletedAllocationAfterFirst = await db.select().from(bookingRequestPaymentAllocations) + .where(eq(bookingRequestPaymentAllocations.id, deletedAllocationId)); + const [deletedInstallmentAfterFirst] = await db.select().from(bookingRequestInstallments) + .where(eq(bookingRequestInstallments.id, deletedInstallmentId)); + const auditsAfterFirst = await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, repairAllocationId)); + + expect(allocationAfterFirst.amount).toBe('60.00'); + expect(deletedAllocationAfterFirst).toHaveLength(0); + expect(deletedInstallmentAfterFirst).toMatchObject({ allocatedAmount: '0.00', status: 'unpaid' }); + expect(changedAfterFirst).toMatchObject({ allocatedAmount: '60.00', status: 'partial' }); + expect(changedAfterFirst.updatedAt.getTime()).toBeGreaterThan(originalTimestamp.getTime()); + expect(unchangedAfterFirst).toMatchObject({ + allocatedAmount: '10.00', status: 'paid', updatedAt: originalTimestamp, + }); + expect(auditsAfterFirst).toEqual([expect.objectContaining({ + propertyId, + action: 'update', + entityType: 'booking_request_payment_allocation', + entityId: repairAllocationId, + previousValue: { amount: '100.00' }, + newValue: expect.objectContaining({ + bookingRequestId: requestId, + paymentId: repairPaymentId, + installmentId: repairInstallmentId, + oldAmount: '100.00', + newAmount: '60.00', + }), + })]); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, deletedAllocationId))).toEqual([ + expect.objectContaining({ + propertyId, + action: 'delete', + entityType: 'booking_request_payment_allocation', + previousValue: { amount: '25.00' }, + newValue: expect.objectContaining({ + bookingRequestId: requestId, + paymentId: deletedPaymentId, + installmentId: deletedInstallmentId, + oldAmount: '25.00', + newAmount: '0.00', + }), + }), + ]); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, repairInstallmentId))).toEqual([ + expect.objectContaining({ + propertyId, + action: 'update', + entityType: 'booking_request_installment', + previousValue: { allocatedAmount: '100.00', status: 'paid' }, + newValue: expect.objectContaining({ + bookingRequestId: requestId, + oldAllocatedAmount: '100.00', + newAllocatedAmount: '60.00', + oldStatus: 'paid', + newStatus: 'partial', + }), + }), + ]); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, deletedInstallmentId))).toEqual([ + expect.objectContaining({ + propertyId, + action: 'update', + entityType: 'booking_request_installment', + previousValue: { allocatedAmount: '25.00', status: 'paid' }, + newValue: expect.objectContaining({ + bookingRequestId: requestId, + oldAllocatedAmount: '25.00', + newAllocatedAmount: '0.00', + oldStatus: 'paid', + newStatus: 'unpaid', + }), + }), + ]); + + const changedTimestamp = changedAfterFirst.updatedAt.getTime(); + const deletedTimestamp = deletedInstallmentAfterFirst.updatedAt.getTime(); + await client.unsafe(financialRecoveryMigration); + const [changedAfterReplay] = await db.select().from(bookingRequestInstallments) + .where(eq(bookingRequestInstallments.id, repairInstallmentId)); + const [unchangedAfterReplay] = await db.select().from(bookingRequestInstallments) + .where(eq(bookingRequestInstallments.id, unchangedInstallmentId)); + const auditsAfterReplay = await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, repairAllocationId)); + const [deletedInstallmentAfterReplay] = await db.select().from(bookingRequestInstallments) + .where(eq(bookingRequestInstallments.id, deletedInstallmentId)); + + expect(changedAfterReplay.updatedAt.getTime()).toBe(changedTimestamp); + expect(unchangedAfterReplay.updatedAt.getTime()).toBe(originalTimestamp.getTime()); + expect(deletedInstallmentAfterReplay.updatedAt.getTime()).toBe(deletedTimestamp); + expect(auditsAfterReplay).toHaveLength(1); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, deletedAllocationId))).toHaveLength(1); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, repairInstallmentId))).toHaveLength(1); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, deletedInstallmentId))).toHaveLength(1); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, unchangedAllocationId))).toHaveLength(0); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, unchangedInstallmentId))).toHaveLength(0); + + const runtimeResetTimestamp = new Date('2026-08-24T19:00:00.000Z'); + await db.update(bookingRequestPaymentAllocations) + .set({ amount: '100.00' }) + .where(eq(bookingRequestPaymentAllocations.id, repairAllocationId)); + await db.update(bookingRequestInstallments) + .set({ allocatedAmount: '100.00', status: 'paid', updatedAt: runtimeResetTimestamp }) + .where(eq(bookingRequestInstallments.id, repairInstallmentId)); + + await client.unsafe(financialRecoveryMigration); + const [repairedAgainAllocation] = await db.select().from(bookingRequestPaymentAllocations) + .where(eq(bookingRequestPaymentAllocations.id, repairAllocationId)); + const [repairedAgainInstallment] = await db.select().from(bookingRequestInstallments) + .where(eq(bookingRequestInstallments.id, repairInstallmentId)); + expect(repairedAgainAllocation.amount).toBe('60.00'); + expect(repairedAgainInstallment).toMatchObject({ allocatedAmount: '60.00', status: 'partial' }); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, repairAllocationId))).toHaveLength(2); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, repairInstallmentId))).toHaveLength(2); + + const secondRepairTimestamp = repairedAgainInstallment.updatedAt.getTime(); + await client.unsafe(financialRecoveryMigration); + const [immediateReplayInstallment] = await db.select().from(bookingRequestInstallments) + .where(eq(bookingRequestInstallments.id, repairInstallmentId)); + expect(immediateReplayInstallment.updatedAt.getTime()).toBe(secondRepairTimestamp); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, repairAllocationId))).toHaveLength(2); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, repairInstallmentId))).toHaveLength(2); + + const runtimeTimestamp = new Date('2026-08-24T20:00:00.000Z'); + const runtimeClient = postgres(databaseUrl!, { max: 1 }); + let markLocked!: () => void; + let releaseRuntime!: () => void; + const runtimeLocked = new Promise((resolve) => { markLocked = resolve; }); + const release = new Promise((resolve) => { releaseRuntime = resolve; }); + const runtimeReduction = runtimeClient.begin(async (sql) => { + await sql.unsafe( + 'SELECT id FROM payments WHERE id = $1 ORDER BY id FOR UPDATE', + [repairPaymentId], + ); + await sql.unsafe( + 'UPDATE booking_request_installments SET allocated_amount = $1, status = $2, updated_at = $3 WHERE id = $4', + ['30.00', 'partial', runtimeTimestamp, repairInstallmentId], + ); + await sql.unsafe( + 'UPDATE booking_request_payment_allocations SET amount = $1 WHERE id = $2', + ['30.00', repairAllocationId], + ); + markLocked(); + await release; + }); + await runtimeLocked; + let migrationSettled = false; + const concurrentRepair = client.unsafe(financialRecoveryMigration) + .finally(() => { migrationSettled = true; }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(migrationSettled).toBe(false); + releaseRuntime(); + await Promise.all([runtimeReduction, concurrentRepair]); + await runtimeClient.end(); + + const [allocationAfterRace] = await db.select().from(bookingRequestPaymentAllocations) + .where(eq(bookingRequestPaymentAllocations.id, repairAllocationId)); + const [installmentAfterRace] = await db.select().from(bookingRequestInstallments) + .where(eq(bookingRequestInstallments.id, repairInstallmentId)); + expect(allocationAfterRace.amount).toBe('30.00'); + expect(installmentAfterRace).toMatchObject({ + allocatedAmount: '30.00', + status: 'partial', + updatedAt: runtimeTimestamp, + }); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, repairAllocationId))).toHaveLength(2); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, repairInstallmentId))).toHaveLength(2); + }); +}); diff --git a/apps/api/src/modules/booking-request/booking-request-payment.service.ts b/apps/api/src/modules/booking-request/booking-request-payment.service.ts new file mode 100644 index 00000000..f4a4b186 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-payment.service.ts @@ -0,0 +1,2356 @@ +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { + auditLogs, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + bookingRequestPaymentResolutions, + bookingRequests, + payments, +} from './booking-request-db.js'; +import { createHash } from 'node:crypto'; +import Decimal from 'decimal.js'; +import { and, asc, eq } from 'drizzle-orm'; +import type { AuditActor } from '../../common/audit/audit-actor'; +import { actorFields } from '../../common/audit/audit-actor'; +import { DRIZZLE } from '../../database/database.module'; +import { FolioService } from '../folio/folio.service'; +import { + PAYMENT_GATEWAY, + type PaymentGateway, + type PaymentGatewayResult, +} from '../payment/interfaces/payment-gateway.interface'; +import { + SAVED_PAYMENT_METHOD_GATEWAY, + type SavedPaymentMethodGateway, +} from '../payment/interfaces/saved-payment-method-gateway.interface'; +import { reconcileBookingRequestPaymentAllocations } from './booking-request-allocation-reconciler'; +import { ensureBookingRequestFinancialConsequence } from './booking-request-payment-consequence'; +import { + summarizeBookingRequestPaymentLedger, + type BookingRequestPaymentLedgerSummary, +} from './booking-request-payment-ledger'; +import { BookingRequestMailerService } from './booking-request-mailer.service'; +import { + assertAllocationAmount, + assertLedgerCurrencySupported, + resolveInstallmentAmount, +} from './booking-request-money'; +import type { + AllocateBookingRequestPaymentDto, + ChargeBookingRequestCardDto, + CreateBookingRequestInstallmentDto, + RecordBookingRequestExternalPaymentDto, + RecordBookingRequestExternalReturnDto, + ReorderBookingRequestInstallmentsDto, + RefundBookingRequestPaymentDto, + RetainBookingRequestPaymentDto, + UpdateBookingRequestInstallmentDto, +} from './dto/booking-request-payment.dto'; + +type RequestRow = typeof bookingRequests.$inferSelect; +type InstallmentRow = typeof bookingRequestInstallments.$inferSelect; +type PaymentRow = typeof payments.$inferSelect; +type ResolutionRow = typeof bookingRequestPaymentResolutions.$inferSelect; +type AllocationRow = typeof bookingRequestPaymentAllocations.$inferSelect; +type DeleteInstallmentResult = + | { outcome: 'deleted'; installmentId: string } + | { outcome: 'trimmed'; installmentId: string; installment: InstallmentRow }; + +type InstallmentMilestone = InstallmentRow['dueMilestone']; + +const EXTERNAL_PAYMENT_METHODS = new Set([ + 'credit_card', + 'debit_card', + 'cash', + 'bank_transfer', + 'pix', + 'other', +]); + +@Injectable() +export class BookingRequestPaymentService { + constructor( + @Inject(DRIZZLE) private readonly db: any, + @Inject(SAVED_PAYMENT_METHOD_GATEWAY) + private readonly savedPaymentMethodGateway: SavedPaymentMethodGateway, + @Inject(FolioService) private readonly folioService: FolioService, + @Inject(PAYMENT_GATEWAY) private readonly paymentGateway: PaymentGateway, + @Inject(BookingRequestMailerService) + private readonly mailer: BookingRequestMailerService, + ) {} + + async listInstallments(bookingRequestId: string, propertyId: string) { + await this.findRequest(this.db, bookingRequestId, propertyId); + const rows = await this.db + .select() + .from(bookingRequestInstallments) + .where(and( + eq(bookingRequestInstallments.bookingRequestId, bookingRequestId), + eq(bookingRequestInstallments.propertyId, propertyId), + )) + .orderBy(asc(bookingRequestInstallments.sortOrder)); + return rows.filter((row: InstallmentRow) => + row.propertyId === propertyId && row.bookingRequestId === bookingRequestId); + } + + async listPayments(bookingRequestId: string, propertyId: string) { + await this.findRequest(this.db, bookingRequestId, propertyId); + const [movementRows, allocationRows, resolutionRows] = await Promise.all([ + this.db + .select() + .from(payments) + .where(and( + eq(payments.bookingRequestId, bookingRequestId), + eq(payments.propertyId, propertyId), + )), + this.db + .select() + .from(bookingRequestPaymentAllocations) + .where(and( + eq(bookingRequestPaymentAllocations.bookingRequestId, bookingRequestId), + eq(bookingRequestPaymentAllocations.propertyId, propertyId), + )), + this.db + .select() + .from(bookingRequestPaymentResolutions) + .where(and( + eq(bookingRequestPaymentResolutions.bookingRequestId, bookingRequestId), + eq(bookingRequestPaymentResolutions.propertyId, propertyId), + )), + ]); + const scopedAllocations = allocationRows.filter((row: AllocationRow) => + row.propertyId === propertyId && row.bookingRequestId === bookingRequestId); + const scopedResolutions = resolutionRows.filter((row: ResolutionRow) => + row.propertyId === propertyId && row.bookingRequestId === bookingRequestId); + const scopedMovements = movementRows.filter((row: PaymentRow) => + row.propertyId === propertyId && row.bookingRequestId === bookingRequestId); + return { + movements: scopedMovements.map((row: PaymentRow) => { + const summary = summarizeBookingRequestPaymentLedger( + row, + scopedMovements, + scopedAllocations, + scopedResolutions, + ); + return { + ...this.paymentResponse(row), + netCapturedAmount: summary.netCaptured.toFixed(2), + allocatedAmount: summary.allocated.toFixed(2), + reservedResolutionAmount: summary.reservedResolution.toFixed(2), + availableToAllocate: summary.availableToAllocate.toFixed(2), + availableToResolve: summary.availableToResolve.toFixed(2), + unresolvedAmount: summary.unresolved.toFixed(2), + returnedAmount: summary.returned.toFixed(2), + retainedAmount: summary.retained.toFixed(2), + availableAmount: summary.availableToAllocate.toFixed(2), + }; + }), + allocations: scopedAllocations, + resolutions: scopedResolutions.map((row: ResolutionRow) => this.resolutionResponse(row)), + }; + } + + async createInstallment( + bookingRequestId: string, + propertyId: string, + input: CreateBookingRequestInstallmentDto, + actor?: AuditActor, + ) { + return this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + this.assertNotDenied(request); + if (this.requestTotal(request).lte(0)) { + throw new ConflictException('A zero-total booking request cannot have installments'); + } + const normalized = this.normalizeInstallment(request, input); + const [created] = await tx + .insert(bookingRequestInstallments) + .values({ + propertyId, + bookingRequestId, + ...normalized, + allocatedAmount: '0.00', + status: 'unpaid', + }) + .returning(); + await this.audit(tx, { + propertyId, + bookingRequestId, + action: 'create', + entityType: 'booking_request_installment', + entityId: created.id, + actor, + newValue: this.installmentAuditValue(created), + description: 'Booking request installment created', + }); + return created; + }); + } + + async updateInstallment( + bookingRequestId: string, + installmentId: string, + propertyId: string, + input: UpdateBookingRequestInstallmentDto, + actor?: AuditActor, + ) { + return this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + this.assertNotDenied(request); + if (this.requestTotal(request).lte(0)) { + throw new ConflictException('A zero-total booking request cannot be allocated'); + } + const existing = await this.findInstallment( + tx, + bookingRequestId, + installmentId, + propertyId, + true, + ); + const persistedAllocation = await this.installmentAllocationTotal( + tx, + bookingRequestId, + installmentId, + propertyId, + ); + const merged: CreateBookingRequestInstallmentDto = { + label: input.label ?? existing.label, + sortOrder: input.sortOrder ?? existing.sortOrder, + dueMilestone: input.dueMilestone ?? existing.dueMilestone, + dueDate: input.dueDate ?? existing.dueDate ?? undefined, + fixedAmount: input.fixedAmount ?? existing.fixedAmount ?? undefined, + percentage: input.percentage ?? existing.percentage ?? undefined, + }; + if (input.fixedAmount != null) merged.percentage = undefined; + if (input.percentage != null) merged.fixedAmount = undefined; + if (merged.dueMilestone !== 'date') merged.dueDate = undefined; + const normalized = this.normalizeInstallment(request, merged); + if (new Decimal(normalized.resolvedAmount).lt(persistedAllocation)) { + throw new ConflictException('Installment total cannot be below its durable allocation'); + } + const allocatedAmount = persistedAllocation.toFixed(2); + const status: InstallmentRow['status'] = persistedAllocation.eq(0) + ? 'unpaid' + : persistedAllocation.gte(normalized.resolvedAmount) + ? 'paid' + : 'partial'; + const updatedAt = new Date(); + const candidates = await tx + .update(bookingRequestInstallments) + .set({ ...normalized, allocatedAmount, status, updatedAt }) + .where(and( + eq(bookingRequestInstallments.id, installmentId), + eq(bookingRequestInstallments.bookingRequestId, bookingRequestId), + eq(bookingRequestInstallments.propertyId, propertyId), + )) + .returning(); + const updated = candidates.find((row: InstallmentRow) => + row.id === installmentId + && row.bookingRequestId === bookingRequestId + && row.propertyId === propertyId) ?? candidates[0]; + if (!updated) throw new NotFoundException(`Installment ${installmentId} not found`); + await this.audit(tx, { + propertyId, + bookingRequestId, + action: 'update', + entityType: 'booking_request_installment', + entityId: installmentId, + actor, + previousValue: this.installmentAuditValue(existing), + newValue: this.installmentAuditValue(updated), + description: 'Booking request installment updated', + }); + return updated; + }); + } + + async deleteInstallment( + bookingRequestId: string, + installmentId: string, + propertyId: string, + actor?: AuditActor, + ): Promise { + return this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + this.assertNotDenied(request); + const existing = await this.findInstallment( + tx, + bookingRequestId, + installmentId, + propertyId, + true, + ); + const persistedAllocation = await this.installmentAllocationTotal( + tx, + bookingRequestId, + installmentId, + propertyId, + ); + if (persistedAllocation.eq(0)) { + await tx + .delete(bookingRequestInstallments) + .where(and( + eq(bookingRequestInstallments.id, installmentId), + eq(bookingRequestInstallments.bookingRequestId, bookingRequestId), + eq(bookingRequestInstallments.propertyId, propertyId), + )); + await this.audit(tx, { + propertyId, + bookingRequestId, + action: 'delete', + entityType: 'booking_request_installment', + entityId: installmentId, + actor, + previousValue: this.installmentAuditValue(existing), + description: 'Booking request installment deleted', + }); + return { outcome: 'deleted', installmentId }; + } + if (persistedAllocation.gte(this.resolvedInstallmentAmount(existing))) { + throw new ConflictException('A fully allocated installment has no removable remainder'); + } + const allocatedAmount = persistedAllocation.toFixed(2); + const candidates = await tx + .update(bookingRequestInstallments) + .set({ + fixedAmount: allocatedAmount, + percentage: null, + resolvedAmount: allocatedAmount, + allocatedAmount, + status: 'paid', + updatedAt: new Date(), + }) + .where(and( + eq(bookingRequestInstallments.id, installmentId), + eq(bookingRequestInstallments.bookingRequestId, bookingRequestId), + eq(bookingRequestInstallments.propertyId, propertyId), + )) + .returning(); + const updated = candidates.find((row: InstallmentRow) => + row.id === installmentId + && row.bookingRequestId === bookingRequestId + && row.propertyId === propertyId) ?? candidates[0]; + if (!updated) throw new NotFoundException(`Installment ${installmentId} not found`); + await this.audit(tx, { + propertyId, + bookingRequestId, + action: 'update', + entityType: 'booking_request_installment', + entityId: installmentId, + actor, + previousValue: this.installmentAuditValue(existing), + newValue: this.installmentAuditValue(updated), + description: 'Booking request unallocated installment remainder removed', + }); + return { outcome: 'trimmed', installmentId, installment: updated }; + }); + } + + async reorderInstallments( + bookingRequestId: string, + propertyId: string, + input: ReorderBookingRequestInstallmentsDto, + actor?: AuditActor, + ): Promise { + return this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + this.assertNotDenied(request); + const selected = await tx + .select() + .from(bookingRequestInstallments) + .where(and( + eq(bookingRequestInstallments.propertyId, propertyId), + eq(bookingRequestInstallments.bookingRequestId, bookingRequestId), + )) + .for('update'); + const installments: InstallmentRow[] = selected.filter((row: InstallmentRow) => + row.propertyId === propertyId && row.bookingRequestId === bookingRequestId); + const uniqueIds = new Set(input.installmentIds); + const existingIds = new Set(installments.map((row) => row.id)); + if ( + uniqueIds.size !== input.installmentIds.length + || input.installmentIds.length !== installments.length + || input.installmentIds.some((id) => !existingIds.has(id)) + ) { + throw new BadRequestException( + 'Installment reorder must contain the exact unique set belonging to the request', + ); + } + + const previousOrder = [...installments] + .sort((left, right) => left.sortOrder - right.sortOrder) + .map((row) => row.id); + const updatedAt = new Date(); + for (const [sortOrder, installmentId] of input.installmentIds.entries()) { + await tx + .update(bookingRequestInstallments) + .set({ sortOrder, updatedAt }) + .where(and( + eq(bookingRequestInstallments.id, installmentId), + eq(bookingRequestInstallments.propertyId, propertyId), + eq(bookingRequestInstallments.bookingRequestId, bookingRequestId), + )); + } + await this.audit(tx, { + propertyId, + bookingRequestId, + action: 'update', + entityType: 'booking_request', + entityId: bookingRequestId, + actor, + previousValue: { requestId: bookingRequestId, order: previousOrder }, + newValue: { requestId: bookingRequestId, order: input.installmentIds }, + description: 'Booking request installments reordered', + }); + const byId = new Map(installments.map((row) => [row.id, row])); + return input.installmentIds.map((id, sortOrder) => ({ + ...byId.get(id)!, + sortOrder, + updatedAt, + })); + }); + } + + async allocatePayment( + bookingRequestId: string, + installmentId: string, + propertyId: string, + input: AllocateBookingRequestPaymentDto, + actor?: AuditActor, + ) { + return this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + if (this.requestTotal(request).lte(0)) { + throw new ConflictException('A zero-total booking request cannot be allocated'); + } + this.assertNotDenied(request); + const payment = await this.findParentPayment( + tx, + bookingRequestId, + input.paymentId, + propertyId, + true, + ); + const installment = await this.findInstallment( + tx, + bookingRequestId, + installmentId, + propertyId, + true, + ); + if (!['captured', 'settled', 'partially_refunded', 'refunded'].includes(payment.status)) { + throw new ConflictException('Only captured payment movements can be allocated'); + } + const amount = this.positiveMoney(input.amount, request.currencyCode, 'Allocation amount'); + const installmentAmount = this.resolvedInstallmentAmount(installment); + const summary = await this.paymentAllocationSummaryFromDatabase( + tx, + bookingRequestId, + propertyId, + payment, + ); + const allocatableNet = summary.availableToResolve; + if (allocatableNet.lte(0)) { + throw new ConflictException('Fully returned payment movement cannot be allocated'); + } + const allocations = await this.scopedAllocations(tx, bookingRequestId, propertyId); + const paymentAllocated = allocations + .filter((row) => row.paymentId === payment.id) + .reduce((sum, row) => sum.plus(row.amount), new Decimal(0)); + const installmentAllocated = allocations + .filter((row) => row.installmentId === installment.id) + .reduce((sum, row) => sum.plus(row.amount), new Decimal(0)); + assertAllocationAmount({ + amount, + movementAmount: allocatableNet, + installmentAmount, + alreadyAllocatedMovementAmount: paymentAllocated, + alreadyAllocatedInstallmentAmount: installmentAllocated, + }); + + const existing = allocations.find((row) => + row.paymentId === payment.id && row.installmentId === installment.id); + let allocation: typeof bookingRequestPaymentAllocations.$inferSelect; + if (existing) { + const cumulative = new Decimal(existing.amount).plus(amount).toFixed(2); + const candidates = await tx + .update(bookingRequestPaymentAllocations) + .set({ amount: cumulative }) + .where(and( + eq(bookingRequestPaymentAllocations.id, existing.id), + eq(bookingRequestPaymentAllocations.propertyId, propertyId), + eq(bookingRequestPaymentAllocations.bookingRequestId, bookingRequestId), + )) + .returning(); + allocation = candidates.find((row: typeof bookingRequestPaymentAllocations.$inferSelect) => + row.id === existing.id) ?? { ...existing, amount: cumulative }; + } else { + [allocation] = await tx + .insert(bookingRequestPaymentAllocations) + .values({ + propertyId, + bookingRequestId, + paymentId: payment.id, + installmentId: installment.id, + amount: amount.toFixed(2), + }) + .returning(); + } + + const currentAllocations = await this.scopedAllocations(tx, bookingRequestId, propertyId); + const allocated = currentAllocations + .filter((row) => row.installmentId === installment.id) + .reduce((sum, row) => sum.plus(row.amount), new Decimal(0)); + const resolved = new Decimal(installmentAmount); + const status: InstallmentRow['status'] = allocated.eq(0) + ? 'unpaid' + : allocated.gte(resolved) + ? 'paid' + : 'partial'; + const installmentCandidates = await tx + .update(bookingRequestInstallments) + .set({ + allocatedAmount: allocated.toFixed(2), + status, + updatedAt: new Date(), + }) + .where(and( + eq(bookingRequestInstallments.id, installment.id), + eq(bookingRequestInstallments.propertyId, propertyId), + eq(bookingRequestInstallments.bookingRequestId, bookingRequestId), + )) + .returning(); + const updatedInstallment = installmentCandidates.find((row: InstallmentRow) => + row.id === installment.id) ?? { + ...installment, + allocatedAmount: allocated.toFixed(2), + status, + }; + await this.audit(tx, { + propertyId, + bookingRequestId, + action: existing ? 'update' : 'create', + entityType: 'booking_request_payment_allocation', + entityId: allocation.id, + actor, + previousValue: existing ? { amount: existing.amount } : undefined, + newValue: { + requestId: bookingRequestId, + paymentId: payment.id, + installmentId: installment.id, + amount: allocation.amount, + }, + description: 'Booking request payment allocated to installment', + }); + return { allocation, installment: updatedInstallment }; + }); + } + + async chargeSavedCard( + bookingRequestId: string, + propertyId: string, + input: ChargeBookingRequestCardDto, + actor?: AuditActor, + ) { + const idempotencyKey = this.scopedKey('charge', propertyId, input.idempotencyKey); + const prepared = await this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + this.assertNotDenied(request); + const amount = this.positiveMoney(input.amount, request.currencyCode, 'Charge amount'); + if (this.requestTotal(request).lte(0)) { + throw new ConflictException('A zero-total booking request cannot be charged'); + } + if (!request.stripeCustomerId || !request.stripePaymentMethodId) { + throw new ConflictException('The booking request has no saved payment method'); + } + + const [created] = await tx + .insert(payments) + .values({ + propertyId, + bookingRequestId, + folioId: request.acceptedFolioId, + idempotencyKey, + method: 'credit_card', + status: 'pending', + amount: amount.toFixed(2), + currencyCode: request.currencyCode, + gatewayProvider: 'stripe', + gatewayPaymentToken: request.stripePaymentMethodId, + cardLastFour: request.cardLastFour, + cardBrand: request.cardBrand, + notes: 'Staff-initiated Booking Request saved-card charge', + }) + .onConflictDoNothing() + .returning(); + if (!created) { + let existing = await this.findPaymentByIdempotency(tx, propertyId, idempotencyKey); + this.assertPaymentReplay(existing, { + bookingRequestId, + amount: amount.toFixed(2), + currencyCode: request.currencyCode, + method: 'credit_card', + }, 'charge idempotency key'); + if (existing.status === 'captured') { + const currentFolioId = request.acceptedFolioId ?? existing.folioId; + if (currentFolioId && existing.folioId !== currentFolioId) { + const candidates = await tx + .update(payments) + .set({ folioId: currentFolioId, updatedAt: new Date() }) + .where(and( + eq(payments.id, existing.id), + eq(payments.propertyId, propertyId), + eq(payments.bookingRequestId, bookingRequestId), + )) + .returning(); + existing = candidates.find((row: PaymentRow) => row.id === existing.id) ?? { + ...existing, + folioId: currentFolioId, + }; + } + if (currentFolioId) { + await this.folioService.recalculateBalance(currentFolioId, propertyId, tx); + } + } + if (existing.status === 'captured' || existing.status === 'failed') { + await ensureBookingRequestFinancialConsequence(tx, { + event: existing.status === 'captured' ? 'payment.received' : 'payment.failed', + logicalId: existing.id, + propertyId, + bookingRequestId, + entityType: 'payment', + entityId: existing.id, + data: { + folioId: request.acceptedFolioId ?? existing.folioId, + status: existing.status, + amount: existing.amount, + currencyCode: existing.currencyCode, + }, + }); + } + return { payment: existing, request, isNew: false }; + } + await this.audit(tx, { + propertyId, + bookingRequestId, + action: 'create', + entityType: 'payment', + entityId: created.id, + actor, + newValue: { + requestId: bookingRequestId, + folioId: request.acceptedFolioId, + amount: created.amount, + currencyCode: created.currencyCode, + method: created.method, + status: 'pending', + }, + description: 'Booking request saved-card charge pending', + }); + return { payment: created, request, isNew: true }; + }); + + if (!prepared.isNew && prepared.payment.status !== 'pending') { + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); + return this.paymentResponse(prepared.payment); + } + + let gatewayResult: Awaited>; + try { + gatewayResult = await this.savedPaymentMethodGateway.charge({ + customerId: prepared.request.stripeCustomerId!, + paymentMethodId: prepared.request.stripePaymentMethodId!, + paymentId: prepared.payment.id, + propertyId, + bookingRequestId, + amount: prepared.payment.amount, + currencyCode: prepared.payment.currencyCode, + idempotencyKey, + }); + } catch (error: unknown) { + const safeMessage = error instanceof Error + ? error.message.slice(0, 500) + : 'Saved-card gateway result is unknown'; + await this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + this.assertNotDenied(request); + const existing = await this.findPayment(tx, prepared.payment.id, propertyId, true); + if (existing.status !== 'pending') return; + await tx + .update(payments) + .set({ + notes: `Gateway result unknown; retry with the same payment identity. ${safeMessage}`, + updatedAt: new Date(), + }) + .where(and( + eq(payments.id, existing.id), + eq(payments.propertyId, propertyId), + eq(payments.bookingRequestId, bookingRequestId), + eq(payments.status, 'pending'), + )); + await this.audit(tx, { + propertyId, + bookingRequestId, + action: 'update', + entityType: 'payment', + entityId: existing.id, + actor, + previousValue: { status: 'pending' }, + newValue: { status: 'pending', result: 'unknown' }, + description: 'Booking request saved-card charge result unknown; retry required', + }); + }); + throw new ServiceUnavailableException( + 'Saved-card gateway result is unknown; retry with the same idempotency key', + ); + } + + if (gatewayResult.indeterminate) { + await this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + this.assertNotDenied(request); + const existing = await this.findPayment(tx, prepared.payment.id, propertyId, true); + if (existing.status !== 'pending') return; + await tx + .update(payments) + .set({ + gatewayTransactionId: gatewayResult.transactionId || existing.gatewayTransactionId, + notes: `Gateway result pending (${gatewayResult.providerStatus ?? 'unknown'}); retry with the same payment identity`, + updatedAt: new Date(), + }) + .where(and( + eq(payments.id, existing.id), + eq(payments.propertyId, propertyId), + eq(payments.bookingRequestId, bookingRequestId), + eq(payments.status, 'pending'), + )); + await this.audit(tx, { + propertyId, + bookingRequestId, + action: 'update', + entityType: 'payment', + entityId: existing.id, + actor, + previousValue: { status: 'pending' }, + newValue: { + status: 'pending', + providerStatus: gatewayResult.providerStatus ?? 'unknown', + }, + description: 'Booking request saved-card charge remains pending at provider', + }); + }); + throw new ServiceUnavailableException( + 'Saved-card gateway result is pending; retry with the same idempotency key', + ); + } + + const finalized = await this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + this.assertNotDenied(request); + const existing = await this.findPayment(tx, prepared.payment.id, propertyId, true); + if (existing.status !== 'pending') return existing; + const status: PaymentRow['status'] = gatewayResult.success ? 'captured' : 'failed'; + const changes = { + status, + folioId: request.acceptedFolioId, + gatewayTransactionId: gatewayResult.transactionId || null, + processedAt: gatewayResult.success ? new Date() : null, + notes: gatewayResult.success + ? 'Staff-initiated Booking Request saved-card charge captured' + : gatewayResult.requiresAction + ? 'Payment failed: additional authentication is required; no recovery link is available' + : `Payment failed: ${gatewayResult.errorMessage ?? 'Gateway declined the charge'}`, + updatedAt: new Date(), + }; + const candidates = await tx + .update(payments) + .set(changes) + .where(and( + eq(payments.id, existing.id), + eq(payments.propertyId, propertyId), + eq(payments.bookingRequestId, bookingRequestId), + eq(payments.status, 'pending'), + )) + .returning(); + const updated = candidates.find((row: PaymentRow) => row.id === existing.id) ?? { + ...existing, + ...changes, + }; + await this.audit(tx, { + propertyId, + bookingRequestId, + action: 'update', + entityType: 'payment', + entityId: updated.id, + actor, + previousValue: { status: 'pending' }, + newValue: { + requestId: bookingRequestId, + folioId: updated.folioId, + amount: updated.amount, + currencyCode: updated.currencyCode, + status, + requiresAction: gatewayResult.requiresAction, + }, + description: status === 'captured' + ? 'Booking request payment captured' + : 'Booking request payment failed', + }); + await ensureBookingRequestFinancialConsequence(tx, { + event: status === 'captured' ? 'payment.received' : 'payment.failed', + logicalId: updated.id, + propertyId, + bookingRequestId, + entityType: 'payment', + entityId: updated.id, + data: { + folioId: updated.folioId, + status, + amount: updated.amount, + currencyCode: updated.currencyCode, + }, + }); + if (status === 'captured' && updated.folioId) { + await this.folioService.recalculateBalance(updated.folioId, propertyId, tx); + } + return updated; + }); + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); + return this.paymentResponse(finalized); + } + + async recordExternalPayment( + bookingRequestId: string, + propertyId: string, + input: RecordBookingRequestExternalPaymentDto, + actor?: AuditActor, + ) { + const reference = input.reference.trim(); + if (!reference) throw new BadRequestException('An external payment reference is required'); + const provider = input.provider?.trim() || 'external'; + const idempotencyKey = this.scopedKey('external', propertyId, reference); + const result = await this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + this.assertNotDenied(request); + if (this.requestTotal(request).lte(0)) { + throw new ConflictException('A zero-total booking request cannot receive payments'); + } + const currencyCode = input.currencyCode.trim().toUpperCase(); + if (currencyCode !== request.currencyCode.toUpperCase()) { + throw new ConflictException( + `External payment currency ${currencyCode} does not match request currency ${request.currencyCode}`, + ); + } + if (!EXTERNAL_PAYMENT_METHODS.has(input.method)) { + throw new BadRequestException(`Unsupported external payment method '${input.method}'`); + } + const amount = this.positiveMoney(input.amount, currencyCode, 'External payment amount'); + const processedAt = this.processedDate(input.processedAt, 'External payment'); + const [created] = await tx + .insert(payments) + .values({ + propertyId, + bookingRequestId, + folioId: request.acceptedFolioId, + idempotencyKey, + method: input.method, + status: 'captured', + amount: amount.toFixed(2), + currencyCode, + gatewayProvider: provider, + gatewayTransactionId: reference, + notes: input.notes?.trim() || null, + processedAt, + }) + .onConflictDoNothing() + .returning(); + if (!created) { + const existing = await this.findPaymentByIdempotency(tx, propertyId, idempotencyKey); + this.assertPaymentReplay(existing, { + bookingRequestId, + amount: amount.toFixed(2), + currencyCode, + method: input.method, + reference, + provider, + processedAt, + notes: input.notes?.trim() || null, + operationPrefix: 'booking-request-external:', + }, 'external payment reference'); + if (existing.folioId) { + await this.folioService.recalculateBalance(existing.folioId, propertyId, tx); + } + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.received', + logicalId: existing.id, + propertyId, + bookingRequestId, + entityType: 'payment', + entityId: existing.id, + data: { + folioId: existing.folioId, + status: existing.status, + amount: existing.amount, + currencyCode: existing.currencyCode, + source: 'external', + }, + }); + return { payment: existing, isNew: false }; + } + await this.audit(tx, { + propertyId, + bookingRequestId, + action: 'create', + entityType: 'payment', + entityId: created.id, + actor, + newValue: { + requestId: bookingRequestId, + folioId: created.folioId, + amount: created.amount, + currencyCode, + method: created.method, + provider, + reference, + processedAt: processedAt.toISOString(), + status: 'captured', + }, + description: 'External booking request payment recorded', + }); + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.received', + logicalId: created.id, + propertyId, + bookingRequestId, + entityType: 'payment', + entityId: created.id, + data: { + folioId: created.folioId, + status: created.status, + amount: created.amount, + currencyCode: created.currencyCode, + source: 'external', + }, + }); + if (created.folioId) { + await this.folioService.recalculateBalance(created.folioId, propertyId, tx); + } + return { payment: created, isNew: true }; + }); + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); + return this.paymentResponse(result.payment); + } + + async refund( + bookingRequestId: string, + paymentId: string, + propertyId: string, + input: RefundBookingRequestPaymentDto, + actor?: AuditActor, + ) { + const idempotencyKey = this.scopedKey('refund', propertyId, input.idempotencyKey); + const prepared = await this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + this.assertNotDenied(request); + const original = await this.findParentPayment( + tx, + bookingRequestId, + paymentId, + propertyId, + true, + ); + if (!original.idempotencyKey?.startsWith('booking-request-charge:')) { + throw new ConflictException( + 'Externally recorded payments must use the external return operation', + ); + } + if (!original.gatewayTransactionId || original.method !== 'credit_card') { + throw new ConflictException('Only a captured gateway card payment can be refunded'); + } + const amount = this.positiveMoney(input.amount, original.currencyCode, 'Refund amount'); + const fingerprint = this.operationFingerprint({ + operation: 'refund', + propertyId, + bookingRequestId, + paymentId, + amount: amount.toFixed(2), + currencyCode: original.currencyCode.toUpperCase(), + gatewayTransactionId: original.gatewayTransactionId, + }); + const replay = await this.findResolutionByIdempotency( + tx, + propertyId, + idempotencyKey, + ); + if (replay) { + this.assertResolutionReplay(replay, fingerprint, 'Refund idempotency key'); + if (replay.status === 'completed' && replay.movementId) { + const movement = await this.findPayment(tx, replay.movementId, propertyId, true); + await this.reconcileAllocationsForPayment( + tx, + bookingRequestId, + propertyId, + original, + actor, + ); + if (movement.folioId) { + await this.folioService.recalculateBalance(movement.folioId, propertyId, tx); + } + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.refunded', + logicalId: movement.id, + propertyId, + bookingRequestId, + entityType: 'payment', + entityId: movement.id, + data: { + folioId: movement.folioId, + originalPaymentId: original.id, + refundAmount: amount.toFixed(2), + currencyCode: original.currencyCode, + resolutionId: replay.id, + }, + }); + return { request, original, amount, claim: replay, movement, terminal: true as const }; + } + if (replay.status === 'failed') { + throw new ConflictException(replay.lastError ?? 'Refund was declined by the gateway'); + } + return { request, original, amount, claim: replay, terminal: false as const }; + } + + await this.assertResolutionCapacity(tx, bookingRequestId, propertyId, original, amount); + const [claim] = await tx + .insert(bookingRequestPaymentResolutions) + .values({ + propertyId, + bookingRequestId, + paymentId, + type: 'refund', + status: 'pending', + amount: amount.toFixed(2), + idempotencyKey, + operationFingerprint: fingerprint, + reason: 'Gateway refund pending', + resolvedBy: actor?.userId ?? null, + resolvedAt: null, + }) + .returning(); + await this.audit(tx, { + propertyId, + bookingRequestId, + action: 'create', + entityType: 'booking_request_payment_resolution', + entityId: claim.id, + actor, + newValue: { + requestId: bookingRequestId, + paymentId, + type: 'refund', + status: 'pending', + amount: amount.toFixed(2), + }, + description: 'Booking request gateway refund capacity claimed', + }); + return { request, original, amount, claim, terminal: false as const }; + }); + if (prepared.terminal) { + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); + return { + movement: this.paymentResponse(prepared.movement), + resolution: this.resolutionResponse(prepared.claim), + }; + } + + let gatewayResult: PaymentGatewayResult; + try { + gatewayResult = await this.paymentGateway.refund( + prepared.original.gatewayTransactionId!, + prepared.amount.toNumber(), + { + idempotencyKey, + currencyCode: prepared.original.currencyCode, + metadata: { + claimId: prepared.claim.id, + propertyId, + bookingRequestId, + paymentId, + }, + }, + ); + } catch (error: unknown) { + await this.recordUnknownResolutionAttempt({ + bookingRequestId, + propertyId, + paymentId, + resolutionId: prepared.claim.id, + error, + actor, + }); + throw new ServiceUnavailableException( + 'Gateway refund result is unknown; retry with the same idempotency key', + ); + } + + const providerStatus = gatewayResult.providerStatus + ?? (gatewayResult.success ? 'succeeded' : 'failed'); + if (providerStatus === 'pending' + || providerStatus === 'requires_action' + || providerStatus === 'unknown') { + await this.recordUnknownResolutionAttempt({ + bookingRequestId, + propertyId, + paymentId, + resolutionId: prepared.claim.id, + error: gatewayResult.errorMessage + ?? `Gateway refund is ${providerStatus}`, + providerTransactionId: gatewayResult.transactionId, + providerStatus, + actor, + }); + throw new ServiceUnavailableException( + `Gateway refund is ${providerStatus}; retry with the same idempotency key`, + ); + } + + if (!gatewayResult.success || providerStatus !== 'succeeded') { + await this.finalizeFailedRefundClaim({ + bookingRequestId, + propertyId, + paymentId, + resolutionId: prepared.claim.id, + errorMessage: gatewayResult.errorMessage ?? 'Gateway declined the refund', + providerTransactionId: gatewayResult.transactionId, + providerStatus, + actor, + }); + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); + throw new ConflictException(`Refund failed: ${gatewayResult.errorMessage ?? 'Gateway declined'}`); + } + + const finalized = await this.finalizeCapturedRefund({ + bookingRequestId, + propertyId, + paymentId, + resolutionId: prepared.claim.id, + idempotencyKey, + gatewayResult, + actor, + }); + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); + return { + ...finalized, + resolution: this.resolutionResponse(finalized.resolution), + }; + } + + async recordExternalReturn( + bookingRequestId: string, + paymentId: string, + propertyId: string, + input: RecordBookingRequestExternalReturnDto, + actor?: AuditActor, + ) { + const processedAt = this.processedDate(input.processedAt, 'External return'); + const reference = input.reference.trim(); + if (!reference) throw new BadRequestException('An external return reference is required'); + const idempotencyKey = this.scopedKey('external-return', propertyId, reference); + const result = await this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + this.assertNotDenied(request); + const original = await this.findParentPayment( + tx, + bookingRequestId, + paymentId, + propertyId, + true, + ); + if (!original.idempotencyKey?.startsWith('booking-request-external:')) { + throw new ConflictException('Gateway payments must use the refund operation'); + } + const amount = this.positiveMoney(input.amount, original.currencyCode, 'External return amount'); + const existing = await this.findOptionalPaymentByIdempotency(tx, propertyId, idempotencyKey); + if (existing) { + const notes = input.notes?.trim() || `External return of payment ${original.id}`; + this.assertPaymentReplay(existing, { + bookingRequestId, + amount: amount.negated().toFixed(2), + currencyCode: original.currencyCode, + method: original.method, + reference, + originalPaymentId: original.id, + provider: original.gatewayProvider, + processedAt, + notes, + operationPrefix: 'booking-request-external-return:', + }, 'external return reference'); + const resolution = await this.ensureResolution(tx, { + bookingRequestId, + propertyId, + paymentId, + type: 'external_return', + amount: amount.toFixed(2), + reason: `External return movement ${existing.id}`, + actor, + marker: existing.id, + movementId: existing.id, + }); + await this.reconcileAllocationsForPayment( + tx, + bookingRequestId, + propertyId, + original, + actor, + ); + if (existing.folioId) { + await this.folioService.recalculateBalance(existing.folioId, propertyId, tx); + } + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.refunded', + logicalId: existing.id, + propertyId, + bookingRequestId, + entityType: 'payment', + entityId: existing.id, + data: { + folioId: existing.folioId, + originalPaymentId: original.id, + returnAmount: amount.toFixed(2), + resolutionId: resolution.id, + currencyCode: original.currencyCode, + source: 'external_return', + method: original.method, + }, + }); + return { movement: existing, resolution, isNew: false }; + } + await this.assertResolutionCapacity( + tx, + bookingRequestId, + propertyId, + original, + amount, + ); + const [movement] = await tx + .insert(payments) + .values({ + propertyId, + bookingRequestId, + folioId: request.acceptedFolioId, + idempotencyKey, + method: original.method, + status: 'captured', + amount: amount.negated().toFixed(2), + currencyCode: original.currencyCode, + gatewayProvider: original.gatewayProvider, + gatewayTransactionId: reference, + originalPaymentId: original.id, + notes: input.notes?.trim() || `External return of payment ${original.id}`, + processedAt, + }) + .returning(); + const resolution = await this.ensureResolution(tx, { + bookingRequestId, + propertyId, + paymentId, + type: 'external_return', + amount: amount.toFixed(2), + reason: `External return movement ${movement.id}`, + actor, + marker: movement.id, + movementId: movement.id, + }); + await this.audit(tx, { + propertyId, + bookingRequestId, + action: 'create', + entityType: 'payment', + entityId: movement.id, + actor, + newValue: { + requestId: bookingRequestId, + folioId: movement.folioId, + originalPaymentId: original.id, + amount: movement.amount, + currencyCode: movement.currencyCode, + type: 'external_return', + reference, + }, + description: 'External booking request payment return recorded', + }); + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.refunded', + logicalId: movement.id, + propertyId, + bookingRequestId, + entityType: 'payment', + entityId: movement.id, + data: { + folioId: movement.folioId, + originalPaymentId: original.id, + returnAmount: amount.toFixed(2), + resolutionId: resolution.id, + currencyCode: original.currencyCode, + source: 'external_return', + method: original.method, + }, + }); + await this.reconcileAllocationsForPayment( + tx, + bookingRequestId, + propertyId, + original, + actor, + ); + if (movement.folioId) { + await this.folioService.recalculateBalance(movement.folioId, propertyId, tx); + } + return { movement, resolution, isNew: true }; + }); + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); + return { + movement: this.paymentResponse(result.movement), + resolution: this.resolutionResponse(result.resolution), + }; + } + + async retainForDenial( + bookingRequestId: string, + paymentId: string, + propertyId: string, + input: RetainBookingRequestPaymentDto, + actor?: AuditActor, + ) { + const reason = input.reason?.trim(); + if (!reason) throw new BadRequestException('A reason is required for retained money'); + const resolution = await this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + if (request.status !== 'pending') { + throw new ConflictException('Money may be retained only for a pending request'); + } + const original = await this.findParentPayment( + tx, + bookingRequestId, + paymentId, + propertyId, + true, + ); + const amount = this.positiveMoney(input.amount, original.currencyCode, 'Retained amount'); + const existing = (await this.scopedResolutions(tx, bookingRequestId, propertyId)) + .find((row) => + row.paymentId === paymentId + && row.type === 'retained' + && new Decimal(row.amount).eq(amount) + && row.reason?.trim() === reason); + if (existing) { + return existing; + } + await this.assertResolutionCapacity( + tx, + bookingRequestId, + propertyId, + original, + amount, + ); + const resolution = await this.ensureResolution(tx, { + bookingRequestId, + propertyId, + paymentId, + type: 'retained', + amount: amount.toFixed(2), + reason, + actor, + }); + return resolution; + }); + return this.resolutionResponse(resolution); + } + + private operationFingerprint(value: Record): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); + } + + private async findResolutionByIdempotency( + db: any, + propertyId: string, + idempotencyKey: string, + ): Promise { + const rows = await db + .select() + .from(bookingRequestPaymentResolutions) + .where(and( + eq(bookingRequestPaymentResolutions.propertyId, propertyId), + eq(bookingRequestPaymentResolutions.idempotencyKey, idempotencyKey), + )); + return rows.find((row: ResolutionRow) => + row.propertyId === propertyId && row.idempotencyKey === idempotencyKey); + } + + private async findResolution( + db: any, + bookingRequestId: string, + paymentId: string, + resolutionId: string, + propertyId: string, + lock = false, + ): Promise { + const query = db + .select() + .from(bookingRequestPaymentResolutions) + .where(and( + eq(bookingRequestPaymentResolutions.id, resolutionId), + eq(bookingRequestPaymentResolutions.propertyId, propertyId), + eq(bookingRequestPaymentResolutions.bookingRequestId, bookingRequestId), + eq(bookingRequestPaymentResolutions.paymentId, paymentId), + )); + const rows = lock ? await query.for('update') : await query; + const resolution = rows.find((row: ResolutionRow) => + row.id === resolutionId + && row.propertyId === propertyId + && row.bookingRequestId === bookingRequestId + && row.paymentId === paymentId); + if (!resolution) throw new NotFoundException(`Payment resolution ${resolutionId} not found`); + return resolution; + } + + private assertResolutionReplay( + resolution: ResolutionRow, + expectedFingerprint: string, + label: string, + ): void { + if (resolution.operationFingerprint !== expectedFingerprint) { + throw new ConflictException(`${label} was already used for different financial data`); + } + } + + private async recordUnknownResolutionAttempt(input: { + bookingRequestId: string; + propertyId: string; + paymentId: string; + resolutionId: string; + error: unknown; + providerTransactionId?: string; + providerStatus?: string; + actor?: AuditActor; + }): Promise { + await this.db.transaction(async (tx: any) => { + const request = await this.findRequest( + tx, + input.bookingRequestId, + input.propertyId, + true, + ); + this.assertNotDenied(request); + await this.findParentPayment( + tx, + input.bookingRequestId, + input.paymentId, + input.propertyId, + true, + ); + const claim = await this.findResolution( + tx, + input.bookingRequestId, + input.paymentId, + input.resolutionId, + input.propertyId, + true, + ); + if (claim.status !== 'pending') return; + const lastError = input.error instanceof Error + ? input.error.message.slice(0, 500) + : typeof input.error === 'string' + ? input.error.slice(0, 500) + : 'Gateway result unknown'; + await tx + .update(bookingRequestPaymentResolutions) + .set({ + attempts: (claim.attempts ?? 0) + 1, + lastError, + providerTransactionId: input.providerTransactionId + || claim.providerTransactionId + || null, + providerStatus: input.providerStatus || claim.providerStatus || 'unknown', + updatedAt: new Date(), + }) + .where(and( + eq(bookingRequestPaymentResolutions.id, claim.id), + eq(bookingRequestPaymentResolutions.propertyId, input.propertyId), + eq(bookingRequestPaymentResolutions.status, 'pending'), + )); + await this.audit(tx, { + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + action: 'update', + entityType: 'booking_request_payment_resolution', + entityId: claim.id, + actor: input.actor, + previousValue: { status: 'pending' }, + newValue: { status: 'pending', result: 'unknown' }, + description: 'Booking request refund result unknown; retry required', + }); + }); + } + + private async finalizeFailedRefundClaim(input: { + bookingRequestId: string; + propertyId: string; + paymentId: string; + resolutionId: string; + errorMessage: string; + providerTransactionId?: string; + providerStatus?: string; + actor?: AuditActor; + }): Promise { + await this.db.transaction(async (tx: any) => { + const request = await this.findRequest( + tx, + input.bookingRequestId, + input.propertyId, + true, + ); + this.assertNotDenied(request); + const parent = await this.findParentPayment( + tx, + input.bookingRequestId, + input.paymentId, + input.propertyId, + true, + ); + const claim = await this.findResolution( + tx, + input.bookingRequestId, + input.paymentId, + input.resolutionId, + input.propertyId, + true, + ); + if (claim.status !== 'pending') return; + await tx + .update(bookingRequestPaymentResolutions) + .set({ + status: 'failed', + attempts: (claim.attempts ?? 0) + 1, + lastError: input.errorMessage.slice(0, 500), + providerTransactionId: input.providerTransactionId + || claim.providerTransactionId + || null, + providerStatus: input.providerStatus || claim.providerStatus || 'failed', + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where(and( + eq(bookingRequestPaymentResolutions.id, claim.id), + eq(bookingRequestPaymentResolutions.propertyId, input.propertyId), + eq(bookingRequestPaymentResolutions.status, 'pending'), + )); + await this.audit(tx, { + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + action: 'update', + entityType: 'booking_request_payment_resolution', + entityId: claim.id, + actor: input.actor, + previousValue: { status: 'pending' }, + newValue: { status: 'failed', error: input.errorMessage.slice(0, 500) }, + description: 'Booking request gateway refund failed', + }); + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.failed', + logicalId: claim.id, + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + entityType: 'booking_request_payment_resolution', + entityId: claim.id, + data: { + paymentId: input.paymentId, + type: 'refund', + amount: claim.amount, + currencyCode: parent.currencyCode, + providerStatus: input.providerStatus ?? 'failed', + }, + }); + }); + } + + private async finalizeCapturedRefund(input: { + bookingRequestId: string; + propertyId: string; + paymentId: string; + resolutionId: string; + idempotencyKey: string; + gatewayResult: PaymentGatewayResult; + actor?: AuditActor; + }) { + return this.db.transaction(async (tx: any) => { + const request = await this.findRequest( + tx, + input.bookingRequestId, + input.propertyId, + true, + ); + this.assertNotDenied(request); + const original = await this.findParentPayment( + tx, + input.bookingRequestId, + input.paymentId, + input.propertyId, + true, + ); + const claim = await this.findResolution( + tx, + input.bookingRequestId, + input.paymentId, + input.resolutionId, + input.propertyId, + true, + ); + if (claim.status === 'completed' && claim.movementId) { + const movement = await this.findPayment(tx, claim.movementId, input.propertyId, true); + await this.reconcileAllocationsForPayment( + tx, + input.bookingRequestId, + input.propertyId, + original, + input.actor, + ); + if (movement.folioId) { + await this.folioService.recalculateBalance(movement.folioId, input.propertyId, tx); + } + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.refunded', + logicalId: movement.id, + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + entityType: 'payment', + entityId: movement.id, + data: { + folioId: movement.folioId, + originalPaymentId: original.id, + refundAmount: claim.amount, + currencyCode: original.currencyCode, + resolutionId: claim.id, + }, + }); + return { movement: this.paymentResponse(movement), resolution: claim }; + } + if (claim.status !== 'pending') { + throw new ConflictException(`Refund claim is '${claim.status}' and cannot be finalized`); + } + + const [movement] = await tx + .insert(payments) + .values({ + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + folioId: request.acceptedFolioId, + idempotencyKey: input.idempotencyKey, + method: original.method, + status: 'captured', + amount: new Decimal(claim.amount).negated().toFixed(2), + currencyCode: original.currencyCode, + gatewayProvider: original.gatewayProvider, + gatewayTransactionId: input.gatewayResult.transactionId, + originalPaymentId: original.id, + notes: `Refund of Booking Request payment ${original.id}`, + processedAt: new Date(), + }) + .returning(); + const resolvedAt = new Date(); + const candidates = await tx + .update(bookingRequestPaymentResolutions) + .set({ + status: 'completed', + movementId: movement.id, + providerTransactionId: input.gatewayResult.transactionId, + providerStatus: input.gatewayResult.providerStatus ?? 'succeeded', + reason: `Gateway refund movement ${movement.id}`, + attempts: (claim.attempts ?? 0) + 1, + lastError: null, + resolvedAt, + updatedAt: resolvedAt, + }) + .where(and( + eq(bookingRequestPaymentResolutions.id, claim.id), + eq(bookingRequestPaymentResolutions.propertyId, input.propertyId), + eq(bookingRequestPaymentResolutions.status, 'pending'), + )) + .returning(); + const resolution = candidates.find((row: ResolutionRow) => row.id === claim.id) ?? { + ...claim, + status: 'completed' as const, + movementId: movement.id, + reason: `Gateway refund movement ${movement.id}`, + attempts: (claim.attempts ?? 0) + 1, + lastError: null, + resolvedAt, + updatedAt: resolvedAt, + }; + await this.audit(tx, { + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + action: 'create', + entityType: 'payment', + entityId: movement.id, + actor: input.actor, + newValue: { + requestId: input.bookingRequestId, + folioId: movement.folioId, + originalPaymentId: original.id, + amount: movement.amount, + currencyCode: movement.currencyCode, + type: 'refund', + }, + description: 'Booking request gateway refund movement captured', + }); + await this.audit(tx, { + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + action: 'update', + entityType: 'booking_request_payment_resolution', + entityId: claim.id, + actor: input.actor, + previousValue: { status: 'pending' }, + newValue: { + status: 'completed', + movementId: movement.id, + amount: claim.amount, + }, + description: 'Booking request gateway refund completed', + }); + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.refunded', + logicalId: movement.id, + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + entityType: 'payment', + entityId: movement.id, + data: { + folioId: movement.folioId, + originalPaymentId: original.id, + refundAmount: claim.amount, + currencyCode: original.currencyCode, + resolutionId: claim.id, + }, + }); + await this.reconcileAllocationsForPayment( + tx, + input.bookingRequestId, + input.propertyId, + original, + input.actor, + ); + if (movement.folioId) { + await this.folioService.recalculateBalance(movement.folioId, input.propertyId, tx); + } + return { movement: this.paymentResponse(movement), resolution }; + }); + } + + private async deliverEmailsBestEffort( + bookingRequestId: string, + propertyId: string, + ): Promise { + try { + await this.mailer.deliverForRequestBestEffort(bookingRequestId, propertyId); + } catch { + // Delivery is a post-commit consequence. Its durable row is recovered by + // the scheduled mail worker and must never fail the completed money move. + } + } + + private normalizeInstallment( + request: RequestRow, + input: CreateBookingRequestInstallmentDto, + ) { + const label = input.label?.trim(); + if (!label) throw new BadRequestException('An installment label is required'); + const dueMilestone = input.dueMilestone as InstallmentMilestone; + if (!['date', 'arrival', 'checkout', 'manual'].includes(dueMilestone)) { + throw new BadRequestException(`Unsupported installment milestone '${dueMilestone}'`); + } + if (dueMilestone === 'date' && !input.dueDate) { + throw new BadRequestException('A due date is required for the date milestone'); + } + if (dueMilestone !== 'date' && input.dueDate) { + throw new BadRequestException('A due date is valid only for the date milestone'); + } + const percentage = input.percentage == null + ? undefined + : this.positivePercentage(input.percentage); + const fixedAmount = input.fixedAmount == null + ? undefined + : this.positiveMoney(input.fixedAmount, request.currencyCode, 'Fixed installment amount'); + const resolved = resolveInstallmentAmount({ + total: this.requestTotal(request), + fixedAmount, + percentage, + currencyExponent: assertLedgerCurrencySupported(request.currencyCode), + }); + return { + label, + sortOrder: input.sortOrder ?? 0, + fixedAmount: fixedAmount?.toFixed(2) ?? null, + percentage: percentage?.toFixed(2) ?? null, + resolvedAmount: resolved.toFixed(2), + dueMilestone, + dueDate: dueMilestone === 'date' ? input.dueDate! : null, + }; + } + + private positivePercentage(value: string): Decimal { + const amount = this.decimal(value, 'Installment percentage'); + if (amount.lte(0)) throw new ConflictException('Installment percentage must be positive'); + if (amount.decimalPlaces() > 2) { + throw new BadRequestException('Installment percentage supports at most two decimal places'); + } + if (amount.gt(100)) { + throw new BadRequestException('Installment percentage cannot exceed 100'); + } + return amount; + } + + private positiveMoney(value: string, currencyCode: string, field: string): Decimal { + const amount = this.decimal(value, field); + if (amount.lte(0)) throw new ConflictException(`${field} must be positive`); + const exponent = assertLedgerCurrencySupported(currencyCode); + if (amount.decimalPlaces() > exponent) { + throw new BadRequestException( + `${field} has fractional minor units for ${currencyCode.toUpperCase()}`, + ); + } + if (amount.decimalPlaces() > 2) { + throw new BadRequestException(`${field} exceeds ledger storage precision`); + } + return amount; + } + + private decimal(value: string, field: string): Decimal { + try { + const amount = new Decimal(value); + if (!amount.isFinite()) throw new Error('not finite'); + return amount; + } catch { + throw new BadRequestException(`Invalid ${field}`); + } + } + + private requestTotal(request: RequestRow): Decimal { + const submitted = request.submittedQuoteSnapshot as Record | null; + const raw = request.status === 'accepted' + ? request.acceptedTotal + : submitted?.['grandTotal']; + if (typeof raw !== 'string') { + throw new ConflictException('Booking request has no authoritative total'); + } + const total = this.decimal(raw, 'booking request total'); + if (total.lt(0)) throw new ConflictException('Booking request total cannot be negative'); + return total; + } + + private processedDate(value: string, label: string): Date { + const date = new Date(value); + if (Number.isNaN(date.getTime())) throw new BadRequestException(`${label} date is invalid`); + if (date.getTime() > Date.now()) { + throw new BadRequestException(`${label} processed date cannot be in the future`); + } + return date; + } + + private scopedKey(kind: string, propertyId: string, clientIdentity: string): string { + const normalized = clientIdentity.trim(); + if (!normalized) throw new BadRequestException(`${kind} idempotency identity is required`); + const digest = createHash('sha256') + .update(`${propertyId}:${normalized}`) + .digest('hex'); + return `booking-request-${kind}:${digest}`; + } + + private assertNotDenied(request: RequestRow): void { + if (request.status === 'denied') { + throw new ConflictException('Cannot move money on a denied booking request'); + } + } + + private async findRequest( + db: any, + bookingRequestId: string, + propertyId: string, + lock = false, + ): Promise { + const query = db + .select() + .from(bookingRequests) + .where(and( + eq(bookingRequests.id, bookingRequestId), + eq(bookingRequests.propertyId, propertyId), + )); + const rows = lock ? await query.for('update') : await query; + const request = rows.find((row: RequestRow) => + row.id === bookingRequestId && row.propertyId === propertyId); + if (!request) throw new NotFoundException(`Booking request ${bookingRequestId} not found`); + return request; + } + + private async findInstallment( + db: any, + bookingRequestId: string, + installmentId: string, + propertyId: string, + lock = false, + ): Promise { + const query = db + .select() + .from(bookingRequestInstallments) + .where(and( + eq(bookingRequestInstallments.id, installmentId), + eq(bookingRequestInstallments.bookingRequestId, bookingRequestId), + eq(bookingRequestInstallments.propertyId, propertyId), + )); + const rows = lock ? await query.for('update') : await query; + const installment = rows.find((row: InstallmentRow) => + row.id === installmentId + && row.bookingRequestId === bookingRequestId + && row.propertyId === propertyId); + if (!installment) throw new NotFoundException(`Installment ${installmentId} not found`); + return installment; + } + + private async findParentPayment( + db: any, + bookingRequestId: string, + paymentId: string, + propertyId: string, + lock = false, + ): Promise { + const payment = await this.findPayment(db, paymentId, propertyId, lock); + if (payment.bookingRequestId !== bookingRequestId || payment.originalPaymentId != null) { + throw new NotFoundException(`Payment ${paymentId} not found`); + } + if (!['captured', 'settled', 'partially_refunded', 'refunded'].includes(payment.status)) { + throw new ConflictException('Payment is not a captured movement'); + } + if (new Decimal(payment.amount).lte(0)) { + throw new ConflictException('Captured payment amount must be positive'); + } + return payment; + } + + private async findPayment( + db: any, + paymentId: string, + propertyId: string, + lock = false, + ): Promise { + const query = db + .select() + .from(payments) + .where(and(eq(payments.id, paymentId), eq(payments.propertyId, propertyId))); + const rows = lock ? await query.for('update') : await query; + const payment = rows.find((row: PaymentRow) => + row.id === paymentId && row.propertyId === propertyId); + if (!payment) throw new NotFoundException(`Payment ${paymentId} not found`); + return payment; + } + + private async findOptionalPaymentByIdempotency( + db: any, + propertyId: string, + idempotencyKey: string, + ): Promise { + const rows = await db + .select() + .from(payments) + .where(and( + eq(payments.propertyId, propertyId), + eq(payments.idempotencyKey, idempotencyKey), + )); + return rows.find((row: PaymentRow) => + row.propertyId === propertyId && row.idempotencyKey === idempotencyKey); + } + + private async findPaymentByIdempotency( + db: any, + propertyId: string, + idempotencyKey: string, + ): Promise { + const payment = await this.findOptionalPaymentByIdempotency( + db, + propertyId, + idempotencyKey, + ); + if (!payment) throw new ConflictException('Idempotent payment could not be recovered'); + return payment; + } + + private assertPaymentReplay( + existing: PaymentRow, + expected: { + bookingRequestId: string; + amount: string; + currencyCode: string; + method: string; + reference?: string; + originalPaymentId?: string; + provider?: string | null; + processedAt?: Date; + notes?: string | null; + operationPrefix?: string; + }, + identityLabel: string, + ): void { + if ( + existing.bookingRequestId !== expected.bookingRequestId + || !new Decimal(existing.amount).eq(expected.amount) + || existing.currencyCode.toUpperCase() !== expected.currencyCode.toUpperCase() + || existing.method !== expected.method + || (expected.reference != null && existing.gatewayTransactionId !== expected.reference) + || (expected.provider !== undefined && existing.gatewayProvider !== expected.provider) + || ( + expected.processedAt != null + && existing.processedAt?.getTime() !== expected.processedAt.getTime() + ) + || (expected.notes !== undefined && existing.notes !== expected.notes) + || ( + expected.operationPrefix != null + && !existing.idempotencyKey?.startsWith(expected.operationPrefix) + ) + || ( + expected.originalPaymentId != null + && existing.originalPaymentId !== expected.originalPaymentId + ) + ) { + throw new ConflictException(`${identityLabel} was already used for different payment data`); + } + } + + private async scopedAllocations( + db: any, + bookingRequestId: string, + propertyId: string, + ): Promise { + const rows = await db + .select() + .from(bookingRequestPaymentAllocations) + .where(and( + eq(bookingRequestPaymentAllocations.bookingRequestId, bookingRequestId), + eq(bookingRequestPaymentAllocations.propertyId, propertyId), + )); + return rows.filter((row: AllocationRow) => + row.bookingRequestId === bookingRequestId && row.propertyId === propertyId); + } + + private async paymentAllocationSummaryFromDatabase( + db: any, + bookingRequestId: string, + propertyId: string, + payment: PaymentRow, + ): Promise { + const [movementRows, allocationRows, resolutionRows] = await Promise.all([ + db + .select() + .from(payments) + .where(and( + eq(payments.bookingRequestId, bookingRequestId), + eq(payments.propertyId, propertyId), + )), + db + .select() + .from(bookingRequestPaymentAllocations) + .where(and( + eq(bookingRequestPaymentAllocations.bookingRequestId, bookingRequestId), + eq(bookingRequestPaymentAllocations.propertyId, propertyId), + )), + db + .select() + .from(bookingRequestPaymentResolutions) + .where(and( + eq(bookingRequestPaymentResolutions.bookingRequestId, bookingRequestId), + eq(bookingRequestPaymentResolutions.propertyId, propertyId), + )), + ]); + const movements = movementRows.filter((row: PaymentRow) => + row.bookingRequestId === bookingRequestId && row.propertyId === propertyId); + const allocations = allocationRows.filter((row: AllocationRow) => + row.bookingRequestId === bookingRequestId && row.propertyId === propertyId); + const resolutions = resolutionRows.filter((row: ResolutionRow) => + row.bookingRequestId === bookingRequestId && row.propertyId === propertyId); + return summarizeBookingRequestPaymentLedger(payment, movements, allocations, resolutions); + } + + private async reconcileAllocationsForPayment( + tx: any, + bookingRequestId: string, + propertyId: string, + payment: PaymentRow, + actor?: AuditActor, + ): Promise { + await reconcileBookingRequestPaymentAllocations(tx, { + bookingRequestId, + propertyId, + payment, + actor, + }); + } + + private async scopedResolutions( + db: any, + bookingRequestId: string, + propertyId: string, + ): Promise { + const rows = await db + .select() + .from(bookingRequestPaymentResolutions) + .where(and( + eq(bookingRequestPaymentResolutions.bookingRequestId, bookingRequestId), + eq(bookingRequestPaymentResolutions.propertyId, propertyId), + )); + return rows.filter((row: ResolutionRow) => + row.bookingRequestId === bookingRequestId && row.propertyId === propertyId); + } + + private async installmentAllocationTotal( + db: any, + bookingRequestId: string, + installmentId: string, + propertyId: string, + ): Promise { + return (await this.scopedAllocations(db, bookingRequestId, propertyId)) + .filter((row) => row.installmentId === installmentId) + .reduce((sum, row) => sum.plus(row.amount), new Decimal(0)); + } + + private resolvedInstallmentAmount(installment: InstallmentRow): string { + if (installment.resolvedAmount == null) { + throw new ConflictException(`Installment ${installment.id} has no resolved amount`); + } + return installment.resolvedAmount; + } + + private async assertResolutionCapacity( + db: any, + bookingRequestId: string, + propertyId: string, + payment: PaymentRow, + amount: Decimal, + ): Promise { + const summary = await this.paymentAllocationSummaryFromDatabase( + db, + bookingRequestId, + propertyId, + payment, + ); + if (amount.gt(summary.availableToResolve)) { + throw new ConflictException( + `Resolution amount ${amount.toFixed(2)} exceeds remaining captured amount ${summary.availableToResolve.toFixed(2)}`, + ); + } + } + + private async recordResolution(input: { + bookingRequestId: string; + propertyId: string; + paymentId: string; + type: ResolutionRow['type']; + amount: string; + reason: string; + actor?: AuditActor; + marker?: string; + }) { + return this.db.transaction(async (tx: any) => { + await this.findRequest(tx, input.bookingRequestId, input.propertyId, true); + const original = await this.findParentPayment( + tx, + input.bookingRequestId, + input.paymentId, + input.propertyId, + true, + ); + const existing = (await this.scopedResolutions( + tx, + input.bookingRequestId, + input.propertyId, + )).find((row) => + row.paymentId === input.paymentId + && row.type === input.type + && input.marker != null + && row.reason?.includes(input.marker)); + if (existing) return existing; + await this.assertResolutionCapacity( + tx, + input.bookingRequestId, + input.propertyId, + original, + new Decimal(input.amount), + ); + return this.ensureResolution(tx, input); + }); + } + + private async ensureResolution( + tx: any, + input: { + bookingRequestId: string; + propertyId: string; + paymentId: string; + type: ResolutionRow['type']; + amount: string; + reason?: string; + actor?: AuditActor; + marker?: string; + movementId?: string; + }, + ) { + if (input.marker) { + const existing = (await this.scopedResolutions( + tx, + input.bookingRequestId, + input.propertyId, + )).find((row) => + row.paymentId === input.paymentId + && row.type === input.type + && row.reason?.includes(input.marker!)); + if (existing) { + if (input.type === 'external_return') { + if (!existing.movementId) { + throw new ConflictException( + 'Existing external return resolution is missing canonical movement provenance', + ); + } + if (input.movementId && existing.movementId !== input.movementId) { + throw new ConflictException('External return resolution movement does not match replay'); + } + } + return existing; + } + } + const [resolution] = await tx + .insert(bookingRequestPaymentResolutions) + .values({ + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + paymentId: input.paymentId, + type: input.type, + status: 'completed', + amount: input.amount, + movementId: input.movementId ?? null, + reason: input.reason ?? null, + resolvedBy: input.actor?.userId ?? null, + resolvedAt: new Date(), + }) + .returning(); + await this.audit(tx, { + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + action: 'create', + entityType: 'booking_request_payment_resolution', + entityId: resolution.id, + actor: input.actor, + newValue: { + requestId: input.bookingRequestId, + paymentId: input.paymentId, + type: input.type, + amount: input.amount, + reason: input.reason ?? null, + }, + description: `Booking request payment ${input.type} resolution recorded`, + }); + return resolution; + } + + private installmentAuditValue(row: Partial) { + return { + requestId: row.bookingRequestId, + label: row.label, + sortOrder: row.sortOrder, + fixedAmount: row.fixedAmount, + percentage: row.percentage, + resolvedAmount: row.resolvedAmount, + dueMilestone: row.dueMilestone, + dueDate: row.dueDate, + allocatedAmount: row.allocatedAmount, + status: row.status, + }; + } + + private paymentResponse(row: PaymentRow) { + const source = row.idempotencyKey?.startsWith('booking-request-charge:') + || row.idempotencyKey?.startsWith('booking-request-refund:') + ? 'saved_card' as const + : 'external' as const; + return { + id: row.id, + propertyId: row.propertyId, + bookingRequestId: row.bookingRequestId, + folioId: row.folioId, + method: row.method, + status: row.status, + amount: row.amount, + currencyCode: row.currencyCode, + source, + gatewayProvider: row.gatewayProvider, + reference: source === 'external' + ? row.gatewayTransactionId + : null, + cardLastFour: row.cardLastFour, + cardBrand: row.cardBrand, + originalPaymentId: row.originalPaymentId, + notes: row.notes, + processedAt: row.processedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + private resolutionResponse(row: ResolutionRow) { + return { + id: row.id, + propertyId: row.propertyId, + bookingRequestId: row.bookingRequestId, + paymentId: row.paymentId, + type: row.type, + status: row.status, + amount: row.amount, + movementId: row.movementId, + reason: row.reason, + resolvedBy: row.resolvedBy, + resolvedAt: row.resolvedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + private async audit( + db: any, + input: { + propertyId: string; + bookingRequestId: string; + action: 'create' | 'update' | 'delete'; + entityType: string; + entityId: string; + actor?: AuditActor; + previousValue?: Record; + newValue?: Record; + description: string; + }, + ): Promise { + await db.insert(auditLogs).values({ + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + action: input.action, + entityType: input.entityType, + entityId: input.entityId, + ...actorFields(input.actor), + previousValue: input.previousValue ?? null, + newValue: input.newValue ?? null, + description: input.description, + }); + } +} diff --git a/apps/api/src/modules/booking-request/booking-request-payment.spec.ts b/apps/api/src/modules/booking-request/booking-request-payment.spec.ts new file mode 100644 index 00000000..7def6266 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-payment.spec.ts @@ -0,0 +1,2650 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { + auditLogs, + bookingRequestConsequences, + bookingRequestEmailDeliveries, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + bookingRequestPaymentResolutions, + bookingRequests, + payments, +} from './booking-request-db.js'; +import { WEBHOOK_EVENTS, type WebhookEvent } from '@telivityhaip/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PERMISSIONS_KEY } from '../auth/permissions.decorator'; +import { BookingRequestController } from './booking-request.controller'; +import { BookingRequestPaymentService } from './booking-request-payment.service'; +import { + AllocateBookingRequestPaymentDto, + ChargeBookingRequestCardDto, + CreateBookingRequestInstallmentDto, + RecordBookingRequestExternalPaymentDto, + RecordBookingRequestExternalReturnDto, + ReorderBookingRequestInstallmentsDto, + RefundBookingRequestPaymentDto, + RetainBookingRequestPaymentDto, +} from './dto/booking-request-payment.dto'; + +const PROPERTY_ID = 'aaaaaaaa-0000-4000-a000-000000000001'; +const OTHER_PROPERTY_ID = 'aaaaaaaa-0000-4000-a000-000000000002'; +const REQUEST_ID = 'bbbbbbbb-0000-4000-a000-000000000001'; +const FOLIO_ID = 'cccccccc-0000-4000-a000-000000000001'; +const PAYMENT_ID = 'dddddddd-0000-4000-a000-000000000001'; +const INSTALLMENT_ID = 'eeeeeeee-0000-4000-a000-000000000001'; + +type State = { + requests: Array>; + installments: Array>; + payments: Array>; + allocations: Array>; + resolutions: Array>; + audits: Array>; + consequences: Array>; + emails: Array>; +}; + +function request(overrides: Record = {}) { + return { + id: REQUEST_ID, + propertyId: PROPERTY_ID, + status: 'pending', + currencyCode: 'EUR', + submittedQuoteSnapshot: { grandTotal: '220.00' }, + acceptedTotal: null, + acceptedFolioId: null, + stripeCustomerId: 'cus_saved', + stripePaymentMethodId: 'pm_saved', + cardLastFour: '4242', + cardBrand: 'visa', + guestFirstName: 'Ada', + guestEmail: 'ada@example.com', + ...overrides, + }; +} + +function installment(overrides: Record = {}) { + return { + id: INSTALLMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + label: 'Deposit', + sortOrder: 0, + fixedAmount: '100.00', + percentage: null, + resolvedAmount: '100.00', + dueMilestone: 'manual', + dueDate: null, + allocatedAmount: '0.00', + status: 'unpaid', + createdAt: new Date('2026-08-24T10:00:00.000Z'), + updatedAt: new Date('2026-08-24T10:00:00.000Z'), + ...overrides, + }; +} + +function capturedPayment(overrides: Record = {}) { + return { + id: PAYMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + houseAccountId: null, + idempotencyKey: 'booking-request-external:existing', + method: 'cash', + status: 'captured', + amount: '100.00', + currencyCode: 'EUR', + gatewayProvider: 'external', + gatewayTransactionId: 'receipt-1', + originalPaymentId: null, + processedAt: new Date('2026-08-20T10:00:00.000Z'), + createdAt: new Date('2026-08-24T10:00:00.000Z'), + updatedAt: new Date('2026-08-24T10:00:00.000Z'), + ...overrides, + }; +} + +function tableRows(state: State, table: unknown): Array> { + if (table === bookingRequests) return state.requests; + if (table === bookingRequestInstallments) return state.installments; + if (table === payments) return state.payments; + if (table === bookingRequestPaymentAllocations) return state.allocations; + if (table === bookingRequestPaymentResolutions) return state.resolutions; + if (table === auditLogs) return state.audits; + if (table === bookingRequestConsequences) return state.consequences; + if (table === bookingRequestEmailDeliveries) return state.emails; + throw new Error('Unexpected table in payment test'); +} + +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; +} + +function makeDatabase(state: State) { + let sequence = 10; + let transactionActive = false; + let lockCalls = 0; + + const select = vi.fn(() => { + let table: unknown; + const rows = () => structuredClone(tableRows(state, table)); + const chain: Record & PromiseLike = { + from: vi.fn((selected: unknown) => { + table = selected; + return chain; + }), + where: vi.fn(() => chain), + orderBy: vi.fn(() => chain), + for: vi.fn(async () => { + lockCalls += 1; + return rows(); + }), + then: (resolve: (value: unknown) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve(rows()).then(resolve, reject), + }; + return chain; + }); + + const insert = vi.fn((table: unknown) => ({ + values: vi.fn((input: Record) => { + let inserted: Record | undefined; + let didAttempt = false; + const doInsert = (ignoreConflict: boolean) => { + if (didAttempt) return inserted ? [structuredClone(inserted)] : []; + didAttempt = true; + const rows = tableRows(state, table); + if (table === payments && input['idempotencyKey'] != null) { + const duplicate = rows.some((row) => + row.propertyId === input['propertyId'] + && row.idempotencyKey === input['idempotencyKey']); + if (duplicate) { + if (ignoreConflict) return []; + throw new Error('duplicate payments_property_idempotency_key_unique'); + } + } + if (table === bookingRequestPaymentAllocations) { + const duplicate = rows.some((row) => + row.paymentId === input['paymentId'] + && row.installmentId === input['installmentId']); + if (duplicate) { + if (ignoreConflict) return []; + throw new Error('duplicate payment allocation'); + } + } + if (table === bookingRequestConsequences) { + const duplicate = rows.some((row) => + row.propertyId === input['propertyId'] + && row.bookingRequestId === input['bookingRequestId'] + && row.kind === input['kind']); + if (duplicate) { + if (ignoreConflict) return []; + throw new Error('duplicate booking request consequence'); + } + } + if (table === bookingRequestEmailDeliveries) { + const duplicate = rows.some((row) => + row.propertyId === input['propertyId'] + && row.bookingRequestId === input['bookingRequestId'] + && row.logicalKey === input['logicalKey']); + if (duplicate) { + if (ignoreConflict) return []; + throw new Error('duplicate booking request email'); + } + } + sequence += 1; + inserted = { + id: input['id'] ?? `00000000-0000-4000-a000-${String(sequence).padStart(12, '0')}`, + ...structuredClone(input), + createdAt: input['createdAt'] ?? new Date(), + updatedAt: input['updatedAt'] ?? new Date(), + }; + rows.push(inserted); + return [structuredClone(inserted)]; + }; + const result: Record & PromiseLike = { + returning: vi.fn(async () => doInsert(false)), + onConflictDoNothing: vi.fn(() => ({ + returning: vi.fn(async () => doInsert(true)), + then: (resolve: (value: unknown) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve().then(() => doInsert(true)).then(resolve, reject), + })), + then: (resolve: (value: unknown) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve().then(() => doInsert(false)).then(resolve, reject), + }; + return result; + }), + })); + + const update = vi.fn((table: unknown) => ({ + set: vi.fn((changes: Record) => ({ + where: vi.fn((predicate: unknown) => { + const apply = () => { + const rows = tableRows(state, table); + const cloneableChanges = { ...changes }; + let targetRows = rows; + if ( + table === bookingRequestInstallments + && typeof cloneableChanges['sortOrder'] === 'object' + ) { + delete cloneableChanges['sortOrder']; + } else if ( + table === bookingRequestInstallments + && typeof cloneableChanges['sortOrder'] === 'number' + ) { + const predicateParts = sqlPredicateParts(predicate); + const targetId = predicateParts.params.find((param) => + rows.some((row) => row.id === param)); + targetRows = rows.filter((row) => row.id === targetId); + } + for (const row of targetRows) Object.assign(row, structuredClone(cloneableChanges)); + return structuredClone(targetRows); + }; + const chain: Record & PromiseLike = { + returning: vi.fn(async () => apply()), + then: (resolve: (value: unknown) => unknown, reject: (error: unknown) => unknown) => + Promise.resolve(apply()).then(resolve, reject), + }; + return chain; + }), + })), + })); + + const remove = vi.fn((table: unknown) => ({ + where: vi.fn(async () => { + const rows = tableRows(state, table); + rows.splice(0, rows.length); + }), + })); + + const db: Record = { select, insert, update, delete: remove }; + db['transaction'] = vi.fn(async (callback: (tx: unknown) => Promise) => { + const snapshot = structuredClone(state); + transactionActive = true; + try { + return await callback(db); + } catch (error) { + for (const key of Object.keys(state) as Array) { + state[key].splice(0, state[key].length, ...snapshot[key]); + } + throw error; + } finally { + transactionActive = false; + } + }); + + return { + db, + isTransactionActive: () => transactionActive, + get lockCalls() { + return lockCalls; + }, + }; +} + +function makeHarness(overrides: Partial = {}) { + const state: State = { + requests: [request()], + installments: [], + payments: [], + allocations: [], + resolutions: [], + audits: [], + consequences: [], + emails: [], + ...structuredClone(overrides), + }; + const database = makeDatabase(state); + const gatewayTransactionStates: boolean[] = []; + const gateway = { + charge: vi.fn(async () => { + gatewayTransactionStates.push(database.isTransactionActive()); + return { + success: true, + transactionId: 'pi_saved_1', + requiresAction: false, + }; + }), + }; + const refundGateway = { + refund: vi.fn().mockResolvedValue({ + success: true, + transactionId: 're_gateway_1', + }), + }; + const folioService = { + recalculateBalance: vi.fn(), + }; + const mailer = { + deliverForRequestBestEffort: vi.fn().mockResolvedValue(undefined), + }; + const service = new (BookingRequestPaymentService as any)( + database.db, + gateway, + folioService, + refundGateway, + mailer, + ) as BookingRequestPaymentService; + return { + service, + state, + database, + gateway, + folioService, + refundGateway, + mailer, + gatewayTransactionStates, + }; +} + +const actor = { + userId: 'ffffffff-0000-4000-a000-000000000001', + userEmail: 'agent@example.com', + ipAddress: '203.0.113.8', +}; + +describe('Booking Request payment HTTP contract', () => { + it('requires reservations.write for every payment-plan and money mutation', () => { + const reflector = new Reflector(); + expect(reflector.get( + PERMISSIONS_KEY, + BookingRequestController.prototype.listPayments, + )).toEqual(['reservations.read']); + for (const method of [ + 'createInstallment', + 'updateInstallment', + 'reorderInstallments', + 'deleteInstallment', + 'allocatePayment', + 'chargeSavedCard', + 'recordExternalPayment', + 'refundPayment', + 'recordExternalReturn', + 'retainForDenial', + ] as const) { + expect(reflector.get( + PERMISSIONS_KEY, + BookingRequestController.prototype[method], + )).toEqual(['reservations.write']); + } + }); + + it('validates positive amounts, UUIDs, milestones, dates, and references in concrete DTOs', async () => { + const validInstallment = plainToInstance(CreateBookingRequestInstallmentDto, { + label: 'Arrival balance', + percentage: '70.00', + sortOrder: 1, + dueMilestone: 'arrival', + }); + expect(await validate(validInstallment)).toHaveLength(0); + expect(await validate(plainToInstance(ReorderBookingRequestInstallmentsDto, { + installmentIds: [INSTALLMENT_ID, INSTALLMENT_ID], + }))).toEqual(expect.arrayContaining([ + expect.objectContaining({ property: 'installmentIds' }), + ])); + expect(await validate(plainToInstance(CreateBookingRequestInstallmentDto, { + label: 'Whole balance', + percentage: '100.00', + dueMilestone: 'manual', + }))).toHaveLength(0); + expect(await validate(plainToInstance(CreateBookingRequestInstallmentDto, { + label: 'Too much', + percentage: '100.01', + dueMilestone: 'manual', + }))).not.toHaveLength(0); + + for (const [Dto, value] of [ + [AllocateBookingRequestPaymentDto, { paymentId: 'not-a-uuid', amount: '0' }], + [ChargeBookingRequestCardDto, { amount: '-1.00', idempotencyKey: '' }], + [RecordBookingRequestExternalPaymentDto, { + amount: '0', method: 'cash', currencyCode: 'EUR', + processedAt: 'not-a-date', reference: '', + }], + [RefundBookingRequestPaymentDto, { amount: '0', idempotencyKey: '' }], + [RecordBookingRequestExternalReturnDto, { + amount: '-1.00', processedAt: 'not-a-date', reference: '', + }], + [RetainBookingRequestPaymentDto, { amount: '0', reason: '' }], + ] as const) { + expect((await validate(plainToInstance(Dto as any, value))).length).toBeGreaterThan(0); + } + }); +}); + +describe('BookingRequestPaymentService installments', () => { + it('resolves fixed and percentage installments and treats all milestones as labels only', async () => { + const harness = makeHarness(); + const inputs = [ + { label: 'Manual deposit', fixedAmount: '44.00', dueMilestone: 'manual' as const }, + { label: 'Dated payment', percentage: '30.00', dueMilestone: 'date' as const, dueDate: '2026-09-01' }, + { label: 'Arrival', fixedAmount: '50.00', dueMilestone: 'arrival' as const }, + { label: 'Checkout', fixedAmount: '60.00', dueMilestone: 'checkout' as const }, + ]; + + const created = []; + for (const input of inputs) { + created.push(await harness.service.createInstallment( + REQUEST_ID, + PROPERTY_ID, + input, + actor, + )); + } + + expect(created.map((row) => row.resolvedAmount)).toEqual([ + '44.00', + '66.00', + '50.00', + '60.00', + ]); + expect(created.map((row) => row.dueMilestone)).toEqual([ + 'manual', + 'date', + 'arrival', + 'checkout', + ]); + expect(harness.gateway.charge).not.toHaveBeenCalled(); + expect(harness.state.payments).toHaveLength(0); + }); + + it('rejects invalid amount definitions and a dated milestone without a due date', async () => { + const harness = makeHarness(); + await expect(harness.service.createInstallment(REQUEST_ID, PROPERTY_ID, { + label: 'Invalid', + fixedAmount: '0', + dueMilestone: 'manual', + }, actor)).rejects.toBeInstanceOf(ConflictException); + await expect(harness.service.createInstallment(REQUEST_ID, PROPERTY_ID, { + label: 'Invalid', + fixedAmount: '10.00', + percentage: '10.00', + dueMilestone: 'manual', + }, actor)).rejects.toThrow(/exactly one/i); + await expect(harness.service.createInstallment(REQUEST_ID, PROPERTY_ID, { + label: 'Dated', + fixedAmount: '10.00', + dueMilestone: 'date', + }, actor)).rejects.toThrow(/due date/i); + await expect(harness.service.createInstallment(REQUEST_ID, PROPERTY_ID, { + label: 'Over one hundred percent', + percentage: '100.01', + dueMilestone: 'manual', + }, actor)).rejects.toThrow(/100/i); + }); + + it('uses ISO zero-decimal rounding and rejects installments for a zero-total request', async () => { + const jpy = makeHarness({ + requests: [request({ + currencyCode: 'JPY', + submittedQuoteSnapshot: { grandTotal: '101' }, + })], + }); + const rounded = await jpy.service.createInstallment(REQUEST_ID, PROPERTY_ID, { + label: 'Half', + percentage: '50', + dueMilestone: 'manual', + }, actor); + expect(rounded.resolvedAmount).toBe('51.00'); + + const zeroTotal = makeHarness({ + requests: [request({ submittedQuoteSnapshot: { grandTotal: '0.00' } })], + }); + await expect(zeroTotal.service.createInstallment(REQUEST_ID, PROPERTY_ID, { + label: 'Invalid plan', + fixedAmount: '10.00', + dueMilestone: 'manual', + }, actor)).rejects.toThrow(/zero-total|positive total/i); + }); + + it('adds partial allocations under locks and recomputes unpaid, partial, and paid state', async () => { + const harness = makeHarness({ + installments: [installment()], + payments: [ + capturedPayment({ id: PAYMENT_ID, amount: '30.00' }), + capturedPayment({ + id: 'dddddddd-0000-4000-a000-000000000002', + amount: '70.00', + gatewayTransactionId: 'receipt-2', + idempotencyKey: 'booking-request-external:second', + }), + ], + }); + + const partial = await harness.service.allocatePayment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { paymentId: PAYMENT_ID, amount: '10.00' }, + actor, + ); + expect(partial.installment).toMatchObject({ allocatedAmount: '10.00', status: 'partial' }); + + const sameMovement = await harness.service.allocatePayment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { paymentId: PAYMENT_ID, amount: '20.00' }, + actor, + ); + expect(sameMovement.allocation.amount).toBe('30.00'); + expect(harness.state.allocations).toHaveLength(1); + + const paid = await harness.service.allocatePayment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { paymentId: 'dddddddd-0000-4000-a000-000000000002', amount: '70.00' }, + actor, + ); + expect(paid.installment).toMatchObject({ allocatedAmount: '100.00', status: 'paid' }); + expect(harness.database.lockCalls).toBeGreaterThanOrEqual(6); + }); + + it('rejects allocation for a zero-total request even when legacy rows already exist', async () => { + const harness = makeHarness({ + requests: [request({ submittedQuoteSnapshot: { grandTotal: '0.00' } })], + installments: [installment()], + payments: [capturedPayment({ amount: '100.00' })], + }); + + await expect(harness.service.allocatePayment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { paymentId: PAYMENT_ID, amount: '10.00' }, + actor, + )).rejects.toThrow(/zero-total|positive total/i); + expect(harness.state.allocations).toHaveLength(0); + }); + + it('allocates only the canonical net captured amount after returns', async () => { + const parent = capturedPayment({ amount: '100.00' }); + const returned = capturedPayment({ + id: 'dddddddd-0000-4000-a000-000000000099', + amount: '-40.00', + originalPaymentId: PAYMENT_ID, + idempotencyKey: 'booking-request-external-return:existing', + }); + const harness = makeHarness({ + installments: [installment({ resolvedAmount: '100.00' })], + payments: [parent, returned], + }); + + await expect(harness.service.allocatePayment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { paymentId: PAYMENT_ID, amount: '60.01' }, + actor, + )).rejects.toThrow(/movement/i); + await harness.service.allocatePayment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { paymentId: PAYMENT_ID, amount: '60.00' }, + actor, + ); + await expect(harness.service.allocatePayment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { paymentId: PAYMENT_ID, amount: '0.01' }, + actor, + )).rejects.toThrow(/movement/i); + }); + + it('reserves pending and legacy completed return resolutions from allocation capacity', async () => { + for (const resolution of [ + { status: 'pending', movementId: null }, + { status: 'completed', movementId: null }, + ]) { + const harness = makeHarness({ + installments: [installment({ resolvedAmount: '100.00' })], + payments: [capturedPayment({ amount: '100.00' })], + resolutions: [{ + id: `00000000-0000-4000-a000-0000000000${resolution.status === 'pending' ? '51' : '52'}`, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + type: 'refund', + amount: '40.00', + ...resolution, + }], + }); + + await expect(harness.service.allocatePayment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { paymentId: PAYMENT_ID, amount: '60.01' }, + actor, + )).rejects.toThrow(/movement/i); + await expect(harness.service.allocatePayment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { paymentId: PAYMENT_ID, amount: '60.00' }, + actor, + )).resolves.toBeDefined(); + } + }); + + it('allows metadata edits to a partially allocated installment without rewriting allocations', async () => { + const durableAllocation = { + id: '99999999-0000-4000-a000-000000000001', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + installmentId: INSTALLMENT_ID, + paymentId: PAYMENT_ID, + amount: '40.00', + }; + const harness = makeHarness({ + installments: [installment({ allocatedAmount: '0.00', status: 'unpaid' })], + allocations: [durableAllocation], + }); + + await expect(harness.service.updateInstallment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { label: 'Updated deposit' }, + actor, + )).resolves.toMatchObject({ + label: 'Updated deposit', + resolvedAmount: '100.00', + allocatedAmount: '40.00', + status: 'partial', + }); + expect(harness.state.allocations).toEqual([durableAllocation]); + }); + + it('allows increasing a partially allocated installment total', async () => { + const harness = makeHarness({ + installments: [installment({ allocatedAmount: '40.00', status: 'partial' })], + allocations: [{ + id: '99999999-0000-4000-a000-000000000002', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + installmentId: INSTALLMENT_ID, + paymentId: PAYMENT_ID, + amount: '40.00', + }], + }); + + await expect(harness.service.updateInstallment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { fixedAmount: '120.00' }, + actor, + )).resolves.toMatchObject({ + fixedAmount: '120.00', + resolvedAmount: '120.00', + allocatedAmount: '40.00', + status: 'partial', + }); + }); + + it('allows reducing a partially allocated installment total to its durable allocation', async () => { + const harness = makeHarness({ + installments: [installment({ allocatedAmount: '40.00', status: 'partial' })], + allocations: [{ + id: '99999999-0000-4000-a000-000000000003', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + installmentId: INSTALLMENT_ID, + paymentId: PAYMENT_ID, + amount: '40.00', + }], + }); + + await expect(harness.service.updateInstallment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { fixedAmount: '40.00' }, + actor, + )).resolves.toMatchObject({ + fixedAmount: '40.00', + resolvedAmount: '40.00', + allocatedAmount: '40.00', + status: 'paid', + }); + }); + + it('rejects reducing an installment below its durable allocation', async () => { + const original = installment({ allocatedAmount: '40.00', status: 'partial' }); + const durableAllocation = { + id: '99999999-0000-4000-a000-000000000004', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + installmentId: INSTALLMENT_ID, + paymentId: PAYMENT_ID, + amount: '40.00', + }; + const harness = makeHarness({ installments: [original], allocations: [durableAllocation] }); + + await expect(harness.service.updateInstallment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { fixedAmount: '39.99' }, + actor, + )).rejects.toThrow(/allocation/i); + expect(harness.state.installments).toEqual([original]); + expect(harness.state.allocations).toEqual([durableAllocation]); + }); + + it('deletes an installment with no durable allocation', async () => { + const harness = makeHarness({ installments: [installment()] }); + + await expect(harness.service.deleteInstallment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + actor, + )).resolves.toEqual({ outcome: 'deleted', installmentId: INSTALLMENT_ID }); + expect(harness.state.installments).toEqual([]); + expect(harness.state.allocations).toEqual([]); + }); + + it('removes only the unallocated remainder of a partially allocated installment', async () => { + const durableAllocation = { + id: '99999999-0000-4000-a000-000000000005', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + installmentId: INSTALLMENT_ID, + paymentId: PAYMENT_ID, + amount: '40.00', + }; + const harness = makeHarness({ + installments: [installment({ allocatedAmount: '0.00', status: 'unpaid' })], + allocations: [durableAllocation], + }); + + await expect(harness.service.deleteInstallment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + actor, + )).resolves.toMatchObject({ + outcome: 'trimmed', + installmentId: INSTALLMENT_ID, + installment: { + fixedAmount: '40.00', + percentage: null, + resolvedAmount: '40.00', + allocatedAmount: '40.00', + status: 'paid', + }, + }); + expect(harness.state.installments).toEqual([expect.objectContaining({ + id: INSTALLMENT_ID, + fixedAmount: '40.00', + percentage: null, + resolvedAmount: '40.00', + allocatedAmount: '40.00', + status: 'paid', + })]); + expect(harness.state.allocations).toEqual([durableAllocation]); + expect(harness.state.audits).toEqual([expect.objectContaining({ + action: 'update', + description: 'Booking request unallocated installment remainder removed', + })]); + }); + + it('rejects removing a fully allocated installment', async () => { + const existing = installment({ allocatedAmount: '100.00', status: 'paid' }); + const durableAllocation = { + id: '99999999-0000-4000-a000-000000000006', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + installmentId: INSTALLMENT_ID, + paymentId: PAYMENT_ID, + amount: '100.00', + }; + const harness = makeHarness({ installments: [existing], allocations: [durableAllocation] }); + + await expect(harness.service.deleteInstallment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + actor, + )).rejects.toThrow(/remainder/i); + expect(harness.state.installments).toEqual([existing]); + expect(harness.state.allocations).toEqual([durableAllocation]); + }); + + it('allows an allocated installment to be reordered without changing financial fields', async () => { + const allocated = installment({ allocatedAmount: '1.00', status: 'partial', sortOrder: 3 }); + const harness = makeHarness({ + installments: [allocated], + allocations: [{ + id: '99999999-0000-4000-a000-000000000007', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + installmentId: INSTALLMENT_ID, + paymentId: PAYMENT_ID, + amount: '1.00', + }], + }); + + const updated = await harness.service.updateInstallment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { sortOrder: 0 }, + actor, + ); + + expect(updated).toMatchObject({ + sortOrder: 0, + label: allocated.label, + allocatedAmount: '1.00', + }); + }); + + it('reorders the exact request installment set atomically and audits once', async () => { + const secondId = 'eeeeeeee-0000-4000-a000-000000000002'; + const harness = makeHarness({ + installments: [ + installment({ sortOrder: 0, allocatedAmount: '50.00', status: 'partial' }), + installment({ id: secondId, sortOrder: 1, label: 'Balance' }), + ], + }); + + const result = await harness.service.reorderInstallments( + REQUEST_ID, + PROPERTY_ID, + { installmentIds: [secondId, INSTALLMENT_ID] }, + actor, + ); + + expect(result.map((row) => [row.id, row.sortOrder])).toEqual([ + [secondId, 0], + [INSTALLMENT_ID, 1], + ]); + expect(harness.state.installments.map((row) => [row.id, row.sortOrder])).toEqual([ + [INSTALLMENT_ID, 1], + [secondId, 0], + ]); + expect(harness.state.audits).toHaveLength(1); + expect(harness.state.audits[0]).toMatchObject({ + entityType: 'booking_request', + entityId: REQUEST_ID, + description: 'Booking request installments reordered', + }); + expect(harness.database.lockCalls).toBeGreaterThanOrEqual(2); + }); + + it('rejects duplicate, missing, and foreign installment IDs without changing persisted order', async () => { + const secondId = 'eeeeeeee-0000-4000-a000-000000000002'; + const otherRequestId = 'bbbbbbbb-0000-4000-a000-000000000002'; + for (const installmentIds of [ + [INSTALLMENT_ID, INSTALLMENT_ID], + [INSTALLMENT_ID], + [INSTALLMENT_ID, 'eeeeeeee-0000-4000-a000-000000000099'], + ]) { + const harness = makeHarness({ + installments: [ + installment({ sortOrder: 0 }), + installment({ id: secondId, sortOrder: 1, label: 'Balance' }), + installment({ + id: 'eeeeeeee-0000-4000-a000-000000000099', + bookingRequestId: otherRequestId, + sortOrder: 0, + }), + ], + }); + const before = harness.state.installments.map((row) => [row.id, row.sortOrder]); + + await expect(harness.service.reorderInstallments( + REQUEST_ID, + PROPERTY_ID, + { installmentIds }, + actor, + )).rejects.toThrow(/exact|duplicate|belong/i); + expect(harness.state.installments.map((row) => [row.id, row.sortOrder])).toEqual(before); + expect(harness.state.audits).toHaveLength(0); + } + }); + + it('rolls back the bulk order if its single audit write fails', async () => { + const secondId = 'eeeeeeee-0000-4000-a000-000000000002'; + const harness = makeHarness({ + installments: [ + installment({ sortOrder: 0 }), + installment({ id: secondId, sortOrder: 1, label: 'Balance' }), + ], + }); + vi.mocked(harness.database.db.insert).mockImplementationOnce(() => ({ + values: vi.fn(() => Promise.reject(new Error('audit unavailable'))), + }) as never); + + await expect(harness.service.reorderInstallments( + REQUEST_ID, + PROPERTY_ID, + { installmentIds: [secondId, INSTALLMENT_ID] }, + actor, + )).rejects.toThrow(/audit unavailable/i); + expect(harness.state.installments.map((row) => row.sortOrder)).toEqual([0, 1]); + expect(harness.state.audits).toHaveLength(0); + }); + + it('locks the request and complete installment set for each concurrent bulk reorder', async () => { + const secondId = 'eeeeeeee-0000-4000-a000-000000000002'; + const harness = makeHarness({ + installments: [ + installment({ sortOrder: 0 }), + installment({ id: secondId, sortOrder: 1, label: 'Balance' }), + ], + }); + + const results = await Promise.all([ + harness.service.reorderInstallments( + REQUEST_ID, + PROPERTY_ID, + { installmentIds: [secondId, INSTALLMENT_ID] }, + actor, + ), + harness.service.reorderInstallments( + REQUEST_ID, + PROPERTY_ID, + { installmentIds: [INSTALLMENT_ID, secondId] }, + actor, + ), + ]); + + expect(results.map((rows) => rows.map((row) => row.sortOrder))).toEqual([ + [0, 1], + [0, 1], + ]); + expect(harness.database.lockCalls).toBeGreaterThanOrEqual(4); + expect(harness.state.audits).toHaveLength(2); + }); + + it('uses locked allocation rows rather than a stale cached allocated amount', async () => { + const harness = makeHarness({ + installments: [installment({ allocatedAmount: '0.00', status: 'unpaid' })], + allocations: [{ + id: '00000000-0000-4000-a000-000000000020', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + installmentId: INSTALLMENT_ID, + amount: '1.00', + }], + }); + + await expect(harness.service.updateInstallment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { label: 'Must stay unchanged' }, + actor, + )).resolves.toMatchObject({ + label: 'Must stay unchanged', + allocatedAmount: '1.00', + status: 'partial', + }); + }); + + it('returns not found for a cross-property installment', async () => { + const harness = makeHarness({ + installments: [installment({ propertyId: OTHER_PROPERTY_ID })], + }); + await expect(harness.service.updateInstallment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { label: 'Changed' }, + actor, + )).rejects.toBeInstanceOf(NotFoundException); + }); +}); + +describe('BookingRequestPaymentService saved-card charges', () => { + it('commits pending before calling the gateway and captures against the request', async () => { + const harness = makeHarness(); + const result = await harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '80.25', idempotencyKey: 'staff-charge-1' }, + actor, + ); + + expect(harness.gatewayTransactionStates).toEqual([false]); + expect(harness.gateway.charge).toHaveBeenCalledWith(expect.objectContaining({ + customerId: 'cus_saved', + paymentMethodId: 'pm_saved', + paymentId: result.id, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + amount: '80.25', + currencyCode: 'EUR', + idempotencyKey: expect.stringContaining('booking-request-charge:'), + })); + expect(result).toMatchObject({ + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + amount: '80.25', + status: 'captured', + }); + expect(result).not.toHaveProperty('gatewayPaymentToken'); + expect(result).not.toHaveProperty('idempotencyKey'); + expect(result).not.toHaveProperty('gatewayTransactionId'); + expect(harness.state.consequences).toEqual([ + expect.objectContaining({ kind: expect.stringMatching(/^payment_received:/) }), + ]); + expect(harness.state.emails).toEqual([ + expect.objectContaining({ + logicalKey: expect.stringMatching(/^payment:/), + kind: 'payment', + status: 'pending', + recipient: 'ada@example.com', + automaticAttempts: 0, + nextAttemptAt: expect.any(Date), + }), + ]); + expect(harness.state.audits).toContainEqual(expect.objectContaining({ + entityType: 'booking_request_email_delivery', + description: 'Booking request payment email queued', + })); + expect(JSON.stringify(harness.state.emails)).not.toMatch( + /cus_saved|pm_saved|pi_saved|booking-request-charge|https?:\/\//i, + ); + expect(harness.mailer.deliverForRequestBestEffort).toHaveBeenCalledWith( + REQUEST_ID, + PROPERTY_ID, + ); + }); + + it('returns a webhook-recovered capture on later API replay without another provider call', async () => { + const harness = makeHarness(); + const input = { amount: '25.00', idempotencyKey: 'crash-before-provider-id-commit' }; + harness.gateway.charge.mockRejectedValueOnce(new Error('process stopped after provider create')); + + await expect(harness.service.chargeSavedCard( + REQUEST_ID, PROPERTY_ID, input, actor, + )).rejects.toThrow(/same idempotency key|unknown/i); + expect(harness.state.payments[0]!.status).toBe('pending'); + expect(harness.state.payments[0]!.gatewayTransactionId).toBeFalsy(); + + Object.assign(harness.state.payments[0]!, { + status: 'captured', + gatewayTransactionId: 'pi_recovered_by_signed_webhook', + processedAt: new Date(), + }); + const replay = await harness.service.chargeSavedCard( + REQUEST_ID, PROPERTY_ID, input, actor, + ); + + expect(replay).toMatchObject({ status: 'captured', id: harness.state.payments[0]!.id }); + expect(harness.gateway.charge).toHaveBeenCalledTimes(1); + }); + + it('returns the existing result for a stable key without calling the gateway again', async () => { + const harness = makeHarness(); + const first = await harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '40.00', idempotencyKey: 'same-charge' }, + actor, + ); + const second = await harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '40.00', idempotencyKey: 'same-charge' }, + actor, + ); + expect(second.id).toBe(first.id); + expect(harness.gateway.charge).toHaveBeenCalledTimes(1); + }); + + it('repairs folio balance recalculation when a captured charge is replayed', async () => { + const harness = makeHarness({ + requests: [request({ + status: 'accepted', + acceptedTotal: '220.00', + acceptedFolioId: FOLIO_ID, + })], + }); + const input = { amount: '40.00', idempotencyKey: 'charge-recalc-replay' }; + await harness.service.chargeSavedCard(REQUEST_ID, PROPERTY_ID, input, actor); + harness.folioService.recalculateBalance.mockClear(); + + await harness.service.chargeSavedCard(REQUEST_ID, PROPERTY_ID, input, actor); + + expect(harness.folioService.recalculateBalance).toHaveBeenCalledWith( + FOLIO_ID, + PROPERTY_ID, + expect.anything(), + ); + expect(harness.gateway.charge).toHaveBeenCalledTimes(1); + }); + + it('keeps an unknown gateway result pending and resumes with the same provider key', async () => { + const harness = makeHarness(); + harness.gateway.charge + .mockRejectedValueOnce(new Error('socket timed out after provider capture')) + .mockResolvedValueOnce({ + success: true, + transactionId: 'pi_recovered', + requiresAction: false, + }); + + await expect(harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '40.00', idempotencyKey: 'timeout-after-capture' }, + actor, + )).rejects.toThrow(/unknown|retry/i); + expect(harness.state.payments).toHaveLength(1); + expect(harness.state.payments[0]).toMatchObject({ status: 'pending' }); + + const recovered = await harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '40.00', idempotencyKey: 'timeout-after-capture' }, + actor, + ); + expect(recovered).toMatchObject({ status: 'captured' }); + expect(recovered).not.toHaveProperty('gatewayTransactionId'); + const keys = harness.gateway.charge.mock.calls.map((call) => call[0].idempotencyKey); + expect(keys).toHaveLength(2); + expect(new Set(keys).size).toBe(1); + }); + + it('persists a processing PaymentIntent identity while leaving the charge pending', async () => { + const harness = makeHarness(); + harness.gateway.charge.mockResolvedValueOnce({ + success: false, + transactionId: 'pi_processing', + requiresAction: false, + indeterminate: true, + providerStatus: 'processing', + errorMessage: 'Payment is processing', + }); + + await expect(harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '40.00', idempotencyKey: 'processing-identity' }, + actor, + )).rejects.toThrow(/pending|retry|processing/i); + expect(harness.state.payments[0]).toMatchObject({ + status: 'pending', + gatewayTransactionId: 'pi_processing', + }); + }); + + it('resumes concurrent callers of the same pending charge with one provider identity', async () => { + const harness = makeHarness(); + let release!: (value: { + success: true; + transactionId: string; + requiresAction: false; + }) => void; + const providerResult = new Promise<{ + success: true; + transactionId: string; + requiresAction: false; + }>((resolve) => { release = resolve; }); + harness.gateway.charge.mockImplementation(() => providerResult); + + const first = harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '40.00', idempotencyKey: 'concurrent-charge' }, + actor, + ); + await vi.waitFor(() => expect(harness.gateway.charge).toHaveBeenCalledTimes(1)); + const second = harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '40.00', idempotencyKey: 'concurrent-charge' }, + actor, + ); + await vi.waitFor(() => expect(harness.gateway.charge).toHaveBeenCalledTimes(2)); + release({ success: true, transactionId: 'pi_concurrent', requiresAction: false }); + + const results = await Promise.all([first, second]); + expect(results.every((result) => result.status === 'captured')).toBe(true); + const keys = harness.gateway.charge.mock.calls.map((call) => call[0].idempotencyKey); + expect(new Set(keys).size).toBe(1); + }); + + it('uses the freshly locked accepted folio when capture finishes after acceptance', async () => { + const harness = makeHarness(); + let release!: (value: { + success: true; + transactionId: string; + requiresAction: false; + }) => void; + harness.gateway.charge.mockImplementation(() => new Promise((resolve) => { + release = resolve; + })); + + const charging = harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '40.00', idempotencyKey: 'accept-during-charge' }, + actor, + ); + await vi.waitFor(() => expect(harness.gateway.charge).toHaveBeenCalledTimes(1)); + Object.assign(harness.state.requests[0], { + status: 'accepted', + acceptedTotal: '220.00', + acceptedFolioId: FOLIO_ID, + }); + release({ success: true, transactionId: 'pi_after_accept', requiresAction: false }); + + const result = await charging; + expect(result).toMatchObject({ status: 'captured', folioId: FOLIO_ID }); + expect(harness.folioService.recalculateBalance).toHaveBeenCalledWith( + FOLIO_ID, + PROPERTY_ID, + expect.anything(), + ); + }); + + it('never finalizes capture after the request becomes denied', async () => { + const harness = makeHarness(); + let release!: (value: { + success: true; + transactionId: string; + requiresAction: false; + }) => void; + harness.gateway.charge.mockImplementation(() => new Promise((resolve) => { + release = resolve; + })); + const charging = harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '40.00', idempotencyKey: 'denial-race' }, + actor, + ); + await vi.waitFor(() => expect(harness.gateway.charge).toHaveBeenCalledTimes(1)); + harness.state.requests[0]!.status = 'denied'; + release({ success: true, transactionId: 'pi_denial_race', requiresAction: false }); + + await expect(charging).rejects.toThrow(/denied/i); + expect(harness.state.payments[0]).toMatchObject({ status: 'pending' }); + }); + + it('scopes the gateway idempotency identity by property', async () => { + const firstProperty = makeHarness(); + const secondProperty = makeHarness({ + requests: [request({ propertyId: OTHER_PROPERTY_ID })], + }); + + await firstProperty.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '40.00', idempotencyKey: 'shared-client-key' }, + actor, + ); + await secondProperty.service.chargeSavedCard( + REQUEST_ID, + OTHER_PROPERTY_ID, + { amount: '40.00', idempotencyKey: 'shared-client-key' }, + actor, + ); + + const firstKey = firstProperty.gateway.charge.mock.calls[0]?.[0].idempotencyKey; + const secondKey = secondProperty.gateway.charge.mock.calls[0]?.[0].idempotencyKey; + expect(firstKey).not.toBe(secondKey); + }); + + it('records gateway decline and additional-authentication outcomes as terminal failures', async () => { + for (const gatewayResult of [ + { success: false, transactionId: 'pi_declined', requiresAction: false, errorMessage: 'Declined' }, + { + success: false, + transactionId: 'pi_auth', + requiresAction: true, + errorMessage: 'Payment requires additional authentication', + }, + ]) { + const harness = makeHarness(); + harness.gateway.charge.mockResolvedValueOnce(gatewayResult); + const result = await harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '25.00', idempotencyKey: gatewayResult.transactionId }, + actor, + ); + expect(result.status).toBe('failed'); + expect(result).not.toHaveProperty('gatewayTransactionId'); + expect(JSON.stringify(result)).not.toMatch(/client_secret|authentication_url|https?:\/\//i); + expect(harness.state.consequences).toEqual([ + expect.objectContaining({ kind: expect.stringMatching(/^payment_failed:/) }), + ]); + expect(harness.state.emails).toEqual([ + expect.objectContaining({ kind: 'failure', logicalKey: expect.stringMatching(/^failure:/) }), + ]); + } + }); + + it('links a new charge to the accepted folio and recalculates only after capture', async () => { + const harness = makeHarness({ + requests: [request({ + status: 'accepted', + acceptedTotal: '220.00', + acceptedFolioId: FOLIO_ID, + })], + }); + const result = await harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '30.00', idempotencyKey: 'after-acceptance' }, + actor, + ); + expect(result).toMatchObject({ bookingRequestId: REQUEST_ID, folioId: FOLIO_ID }); + expect(harness.folioService.recalculateBalance).toHaveBeenCalledWith( + FOLIO_ID, + PROPERTY_ID, + expect.anything(), + ); + }); + + it('rejects zero total, missing card, denied request, and cross-property scope before gateway side effects', async () => { + for (const row of [ + request({ submittedQuoteSnapshot: { grandTotal: '0.00' } }), + request({ stripeCustomerId: null, stripePaymentMethodId: null }), + request({ status: 'denied' }), + request({ propertyId: OTHER_PROPERTY_ID }), + ]) { + const harness = makeHarness({ requests: [row] }); + await expect(harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '10.00', idempotencyKey: 'blocked' }, + actor, + )).rejects.toThrow(); + expect(harness.gateway.charge).not.toHaveBeenCalled(); + expect(harness.state.payments).toHaveLength(0); + } + }); + + it('rejects currencies whose minor units exceed the scale-two ledger before gateway I/O', async () => { + const harness = makeHarness({ + requests: [request({ + currencyCode: 'BHD', + submittedQuoteSnapshot: { grandTotal: '100.000' }, + })], + }); + + await expect(harness.service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '1.00', idempotencyKey: 'unsupported-bhd' }, + actor, + )).rejects.toThrow(/BHD.*scale-two payment ledger/i); + expect(harness.gateway.charge).not.toHaveBeenCalled(); + expect(harness.state.payments).toHaveLength(0); + }); +}); + +describe('BookingRequestPaymentService external movements and denial resolutions', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('lists request-scoped movements, allocations, and resolutions for staff detail', async () => { + const harness = makeHarness({ + payments: [ + capturedPayment(), + capturedPayment({ + id: 'dddddddd-0000-4000-a000-000000000009', + propertyId: OTHER_PROPERTY_ID, + }), + ], + allocations: [{ + id: '00000000-0000-4000-a000-000000000011', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + installmentId: INSTALLMENT_ID, + amount: '10.00', + }], + resolutions: [{ + id: '00000000-0000-4000-a000-000000000012', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + type: 'retained', + status: 'completed', + amount: '10.00', + reason: 'Supplier fee', + idempotencyKey: 'internal-resolution-key', + operationFingerprint: 'internal-fingerprint', + providerTransactionId: 're_internal', + providerStatus: 'succeeded', + }], + }); + + const result = await harness.service.listPayments(REQUEST_ID, PROPERTY_ID); + expect(result.movements).toHaveLength(1); + expect(result.movements[0]).not.toHaveProperty('gatewayPaymentToken'); + expect(result.movements[0]).not.toHaveProperty('idempotencyKey'); + expect(result.movements[0]).toMatchObject({ + allocatedAmount: '10.00', + reservedResolutionAmount: '0.00', + availableAmount: '80.00', + }); + expect(result.allocations).toHaveLength(1); + expect(result.resolutions).toHaveLength(1); + expect(result.resolutions[0]).not.toHaveProperty('idempotencyKey'); + expect(result.resolutions[0]).not.toHaveProperty('operationFingerprint'); + expect(result.resolutions[0]).not.toHaveProperty('providerTransactionId'); + expect(result.resolutions[0]).not.toHaveProperty('providerStatus'); + }); + + it('derives allocation availability from the canonical ledger without double-counting resolution evidence', async () => { + const parent = (id: string, status: string) => capturedPayment({ id, status }); + const child = (id: string, originalPaymentId: string, amount: string) => capturedPayment({ + id, + amount, + originalPaymentId, + idempotencyKey: `booking-request-refund:${id}`, + }); + const capturedId = 'dddddddd-0000-4000-a000-000000000011'; + const settledId = 'dddddddd-0000-4000-a000-000000000012'; + const partialId = 'dddddddd-0000-4000-a000-000000000013'; + const fullId = 'dddddddd-0000-4000-a000-000000000014'; + const legacyId = 'dddddddd-0000-4000-a000-000000000015'; + const pendingId = 'dddddddd-0000-4000-a000-000000000016'; + const partialChildId = 'dddddddd-0000-4000-a000-000000000113'; + const fullChildId = 'dddddddd-0000-4000-a000-000000000114'; + const harness = makeHarness({ + payments: [ + parent(capturedId, 'captured'), + parent(settledId, 'settled'), + parent(partialId, 'partially_refunded'), + child(partialChildId, partialId, '-40.00'), + parent(fullId, 'refunded'), + child(fullChildId, fullId, '-100.00'), + parent(legacyId, 'partially_refunded'), + parent(pendingId, 'captured'), + ], + allocations: [{ + id: '00000000-0000-4000-a000-000000000021', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: partialId, + installmentId: INSTALLMENT_ID, + amount: '10.00', + }], + resolutions: [ + { + id: '00000000-0000-4000-a000-000000000031', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: partialId, + type: 'refund', + status: 'completed', + amount: '40.00', + movementId: partialChildId, + }, + { + id: '00000000-0000-4000-a000-000000000032', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: legacyId, + type: 'refund', + status: 'completed', + amount: '40.00', + movementId: null, + }, + { + id: '00000000-0000-4000-a000-000000000033', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: pendingId, + type: 'external_return', + status: 'pending', + amount: '25.00', + movementId: null, + }, + ], + }); + + const result = await harness.service.listPayments(REQUEST_ID, PROPERTY_ID); + const byId = new Map(result.movements.map((movement) => [movement.id, movement])); + expect(byId.get(capturedId)).toMatchObject({ netCapturedAmount: '100.00', availableAmount: '100.00' }); + expect(byId.get(settledId)).toMatchObject({ netCapturedAmount: '100.00', availableAmount: '100.00' }); + expect(byId.get(partialId)).toMatchObject({ + netCapturedAmount: '60.00', + allocatedAmount: '10.00', + reservedResolutionAmount: '0.00', + availableToAllocate: '50.00', + availableToResolve: '60.00', + unresolvedAmount: '60.00', + }); + expect(byId.get(fullId)).toMatchObject({ + netCapturedAmount: '0.00', + availableToAllocate: '0.00', + availableToResolve: '0.00', + unresolvedAmount: '0.00', + }); + expect(byId.get(legacyId)).toMatchObject({ + netCapturedAmount: '100.00', + reservedResolutionAmount: '0.00', + availableToAllocate: '60.00', + availableToResolve: '60.00', + unresolvedAmount: '60.00', + }); + expect(byId.get(pendingId)).toMatchObject({ + netCapturedAmount: '100.00', + reservedResolutionAmount: '25.00', + availableToAllocate: '75.00', + availableToResolve: '75.00', + unresolvedAmount: '100.00', + }); + }); + + it('uses generic negative child movements and durable claims for every resolution capacity', async () => { + const legacyChildId = 'dddddddd-0000-4000-a000-000000000151'; + const harness = makeHarness({ + payments: [ + capturedPayment({ amount: '100.00' }), + capturedPayment({ + id: legacyChildId, + originalPaymentId: PAYMENT_ID, + amount: '-40.00', + idempotencyKey: 'legacy-generic-return', + }), + ], + resolutions: [{ + id: '00000000-0000-4000-a000-000000000061', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + type: 'retained', + status: 'completed', + amount: '10.00', + movementId: null, + reason: 'Supplier fee', + }, { + id: '00000000-0000-4000-a000-000000000062', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + type: 'refund', + status: 'pending', + amount: '20.00', + movementId: null, + reason: 'Pending durable claim', + }], + }); + + await expect(harness.service.recordExternalReturn( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { + amount: '30.01', + processedAt: '2026-08-20T10:00:00.000Z', + reference: 'would-overdraw-canonical-net', + }, + actor, + )).rejects.toThrow(/remaining/i); + await expect(harness.service.retainForDenial( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '30.01', reason: 'Would overdraw canonical net' }, + actor, + )).rejects.toThrow(/remaining/i); + + const list = await harness.service.listPayments(REQUEST_ID, PROPERTY_ID); + expect(list.movements.find((movement) => movement.id === PAYMENT_ID)).toMatchObject({ + netCapturedAmount: '60.00', + reservedResolutionAmount: '20.00', + availableToResolve: '30.00', + unresolvedAmount: '50.00', + }); + }); + + it('returns trusted payment provenance independent of an external provider label', async () => { + const saved = capturedPayment({ + id: 'dddddddd-0000-4000-a000-000000000041', + idempotencyKey: 'booking-request-charge:saved', + gatewayProvider: 'stripe', + method: 'credit_card', + }); + const externalStripe = capturedPayment({ + id: 'dddddddd-0000-4000-a000-000000000042', + idempotencyKey: 'booking-request-external:terminal', + gatewayProvider: 'stripe', + method: 'credit_card', + }); + const result = await makeHarness({ payments: [saved, externalStripe] }) + .service.listPayments(REQUEST_ID, PROPERTY_ID); + + expect(result.movements).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: saved.id, source: 'saved_card' }), + expect.objectContaining({ id: externalStripe.id, source: 'external' }), + ])); + expect(result.movements.every((movement) => !('idempotencyKey' in movement))).toBe(true); + }); + + it('records an exact external payment with processed date/reference and rejects duplicate reference', async () => { + const harness = makeHarness(); + const input = { + amount: '75.10', + currencyCode: 'EUR', + method: 'bank_transfer' as const, + processedAt: '2026-08-20T10:00:00.000Z', + provider: 'bank', + reference: 'wire-abc', + notes: 'Deposit received', + }; + const first = await harness.service.recordExternalPayment( + REQUEST_ID, + PROPERTY_ID, + input, + actor, + ); + const second = await harness.service.recordExternalPayment( + REQUEST_ID, + PROPERTY_ID, + input, + actor, + ); + expect(first).toMatchObject({ + bookingRequestId: REQUEST_ID, + folioId: null, + status: 'captured', + amount: '75.10', + processedAt: new Date(input.processedAt), + gatewayProvider: 'bank', + reference: 'wire-abc', + }); + expect(second.id).toBe(first.id); + expect(harness.state.payments).toHaveLength(1); + expect(harness.state.consequences).toEqual([ + expect.objectContaining({ kind: expect.stringMatching(/^payment_received:/) }), + ]); + expect(harness.state.emails).toHaveLength(1); + expect(harness.state.emails[0]).toMatchObject({ kind: 'payment' }); + expect(JSON.stringify(harness.state.emails[0])).not.toContain('wire-abc'); + + await expect(harness.service.recordExternalPayment( + REQUEST_ID, + PROPERTY_ID, + { ...input, amount: '75.11' }, + actor, + )).rejects.toThrow(/reference/i); + for (const changed of [ + { ...input, processedAt: '2026-08-20T11:00:00.000Z' }, + { ...input, method: 'cash' as const }, + { ...input, provider: 'different-bank' }, + { ...input, notes: 'Different note' }, + ]) { + await expect(harness.service.recordExternalPayment( + REQUEST_ID, + PROPERTY_ID, + changed, + actor, + )).rejects.toThrow(/reference|different financial data/i); + } + expect(harness.state.payments).toHaveLength(1); + }); + + it('rejects external money for zero-total requests and unsupported scale-three currencies', async () => { + const zeroTotal = makeHarness({ + requests: [request({ submittedQuoteSnapshot: { grandTotal: '0.00' } })], + }); + await expect(zeroTotal.service.recordExternalPayment( + REQUEST_ID, + PROPERTY_ID, + { + amount: '10.00', + currencyCode: 'EUR', + method: 'cash', + processedAt: '2026-08-20T10:00:00.000Z', + reference: 'zero-total-payment', + }, + actor, + )).rejects.toThrow(/zero-total|positive total/i); + + const bhd = makeHarness({ + requests: [request({ + currencyCode: 'BHD', + submittedQuoteSnapshot: { grandTotal: '100.000' }, + })], + }); + await expect(bhd.service.recordExternalPayment( + REQUEST_ID, + PROPERTY_ID, + { + amount: '1.00', + currencyCode: 'BHD', + method: 'cash', + processedAt: '2026-08-20T10:00:00.000Z', + reference: 'unsupported-bhd', + }, + actor, + )).rejects.toThrow(/BHD.*scale-two payment ledger/i); + expect(bhd.state.payments).toHaveLength(0); + }); + + it('records external money after acceptance directly on the linked folio', async () => { + const harness = makeHarness({ + requests: [request({ + status: 'accepted', + acceptedTotal: '220.00', + acceptedFolioId: FOLIO_ID, + })], + }); + const movement = await harness.service.recordExternalPayment( + REQUEST_ID, + PROPERTY_ID, + { + amount: '20.00', + currencyCode: 'EUR', + method: 'cash', + processedAt: '2026-08-20T10:00:00.000Z', + reference: 'cash-receipt-1', + }, + actor, + ); + expect(movement).toMatchObject({ bookingRequestId: REQUEST_ID, folioId: FOLIO_ID }); + expect(harness.folioService.recalculateBalance).toHaveBeenCalledWith( + FOLIO_ID, + PROPERTY_ID, + expect.anything(), + ); + }); + + it('repairs a missed folio recalculation when an external-payment replay follows acceptance', async () => { + const harness = makeHarness(); + const input = { + amount: '20.00', + currencyCode: 'EUR', + method: 'cash' as const, + processedAt: '2026-08-20T10:00:00.000Z', + reference: 'recalc-replay', + }; + await harness.service.recordExternalPayment( + REQUEST_ID, + PROPERTY_ID, + input, + actor, + ); + Object.assign(harness.state.requests[0], { + status: 'accepted', + acceptedTotal: '220.00', + acceptedFolioId: FOLIO_ID, + }); + harness.state.payments[0]!.folioId = FOLIO_ID; + + await harness.service.recordExternalPayment( + REQUEST_ID, + PROPERTY_ID, + input, + actor, + ); + expect(harness.folioService.recalculateBalance).toHaveBeenCalledWith( + FOLIO_ID, + PROPERTY_ID, + expect.anything(), + ); + }); + + it('reuses canonical partial-refund semantics and persists a refund resolution', async () => { + const original = capturedPayment({ + method: 'credit_card', + idempotencyKey: 'booking-request-charge:original', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_original', + }); + const harness = makeHarness({ payments: [original] }); + + const result = await harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '35.00', idempotencyKey: 'partial-refund-1' }, + actor, + ); + expect(harness.refundGateway.refund).toHaveBeenCalledWith( + 'pi_original', + 35, + expect.objectContaining({ + idempotencyKey: expect.stringContaining('booking-request-refund:'), + currencyCode: 'EUR', + }), + ); + expect(result.movement).toMatchObject({ + originalPaymentId: PAYMENT_ID, + amount: '-35.00', + }); + expect(result.resolution).toMatchObject({ + paymentId: PAYMENT_ID, + type: 'refund', + amount: '35.00', + }); + expect(harness.state.consequences).toEqual([ + expect.objectContaining({ kind: expect.stringMatching(/^payment_refunded:/) }), + ]); + expect(harness.state.emails).toEqual([ + expect.objectContaining({ kind: 'refund', logicalKey: expect.stringMatching(/^refund:/) }), + ]); + }); + + it('persists a refund capacity claim before gateway I/O and recovers an unknown result', async () => { + const original = capturedPayment({ + method: 'credit_card', + idempotencyKey: 'booking-request-charge:original', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_original', + }); + const harness = makeHarness({ payments: [original] }); + harness.refundGateway.refund + .mockRejectedValueOnce(new Error('timeout after refund submission')) + .mockResolvedValueOnce({ success: true, transactionId: 're_recovered' }); + + await expect(harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '50.00', idempotencyKey: 'refund-timeout' }, + actor, + )).rejects.toThrow(/unknown|retry/i); + expect(harness.state.resolutions).toEqual([ + expect.objectContaining({ + paymentId: PAYMENT_ID, + type: 'refund', + amount: '50.00', + status: 'pending', + }), + ]); + expect(harness.state.payments).toHaveLength(1); + + const recovered = await harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '50.00', idempotencyKey: 'refund-timeout' }, + actor, + ); + expect(recovered).toMatchObject({ + movement: { amount: '-50.00', originalPaymentId: PAYMENT_ID }, + resolution: { status: 'completed', amount: '50.00' }, + }); + const keys = harness.refundGateway.refund.mock.calls.map((call) => call[2].idempotencyKey); + expect(keys).toHaveLength(2); + expect(new Set(keys).size).toBe(1); + }); + + it.each(['pending', 'requires_action', 'unknown'] as const)( + 'keeps a provider %s refund durable and retryable with exact correlation', + async (providerStatus) => { + const harness = makeHarness({ + payments: [capturedPayment({ + method: 'credit_card', + idempotencyKey: 'booking-request-charge:original', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_original', + })], + }); + harness.refundGateway.refund.mockResolvedValueOnce({ + success: false, + transactionId: `re_${providerStatus}`, + providerStatus, + }); + + await expect(harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '25.00', idempotencyKey: `refund-${providerStatus}` }, + actor, + )).rejects.toThrow(/pending|retry|unknown/i); + expect(harness.state.resolutions[0]).toMatchObject({ + status: 'pending', + providerTransactionId: `re_${providerStatus}`, + providerStatus, + }); + expect(harness.refundGateway.refund).toHaveBeenCalledWith( + 'pi_original', + 25, + expect.objectContaining({ + metadata: expect.objectContaining({ + claimId: harness.state.resolutions[0]!.id, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + }), + }), + ); + }, + ); + + it('replays a provider-pending refund with the same claim and provider identity', async () => { + const harness = makeHarness({ + payments: [capturedPayment({ + method: 'credit_card', + idempotencyKey: 'booking-request-charge:original', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_original', + })], + }); + harness.refundGateway.refund + .mockResolvedValueOnce({ + success: false, + transactionId: 're_processing', + providerStatus: 'pending', + }) + .mockResolvedValueOnce({ + success: true, + transactionId: 're_processing', + providerStatus: 'succeeded', + }); + const input = { amount: '25.00', idempotencyKey: 'provider-pending-replay' }; + + await expect(harness.service.refund( + REQUEST_ID, PAYMENT_ID, PROPERTY_ID, input, actor, + )).rejects.toThrow(/pending|retry/i); + const claimId = harness.state.resolutions[0]!.id; + const recovered = await harness.service.refund( + REQUEST_ID, PAYMENT_ID, PROPERTY_ID, input, actor, + ); + + expect(recovered.resolution).toMatchObject({ + id: claimId, + status: 'completed', + }); + expect(recovered.resolution).not.toHaveProperty('providerTransactionId'); + expect(harness.state.resolutions[0]!.providerTransactionId).toBe('re_processing'); + const options = harness.refundGateway.refund.mock.calls.map((call) => call[2]); + expect(new Set(options.map((option) => option.idempotencyKey)).size).toBe(1); + expect(options.every((option) => option.metadata.claimId === claimId)).toBe(true); + }); + + it.each(['failed', 'canceled'] as const)( + 'fails a provider %s refund claim and releases its reserved capacity', + async (providerStatus) => { + const harness = makeHarness({ + payments: [capturedPayment({ + method: 'credit_card', + idempotencyKey: 'booking-request-charge:original', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_original', + })], + }); + harness.refundGateway.refund.mockResolvedValueOnce({ + success: false, + transactionId: `re_${providerStatus}`, + providerStatus, + errorMessage: `Refund ${providerStatus}`, + }); + + await expect(harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '25.00', idempotencyKey: `refund-${providerStatus}` }, + actor, + )).rejects.toThrow(providerStatus); + expect(harness.state.resolutions[0]).toMatchObject({ + status: 'failed', + providerTransactionId: `re_${providerStatus}`, + providerStatus, + }); + }, + ); + + it('reserves refund capacity across different keys and competing retention', async () => { + const original = capturedPayment({ + method: 'credit_card', + idempotencyKey: 'booking-request-charge:original', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_original', + }); + const harness = makeHarness({ + payments: [original], + resolutions: [{ + id: '99999999-0000-4000-a000-000000000001', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + type: 'refund', + amount: '50.00', + status: 'pending', + idempotencyKey: 'booking-request-refund:pending', + operationFingerprint: 'pending-fingerprint', + reason: 'Gateway refund pending', + }], + }); + + await expect(harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '50.01', idempotencyKey: 'second-refund' }, + actor, + )).rejects.toThrow(/remaining/i); + await expect(harness.service.retainForDenial( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '50.01', reason: 'Competing retained amount' }, + actor, + )).rejects.toThrow(/remaining/i); + expect(harness.refundGateway.refund).not.toHaveBeenCalled(); + }); + + it('allows two distinct fifty-unit claims to exactly exhaust a one-hundred payment', async () => { + const harness = makeHarness({ + payments: [capturedPayment({ + method: 'credit_card', + amount: '100.00', + idempotencyKey: 'booking-request-charge:original', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_original', + })], + }); + + await harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '50.00', idempotencyKey: 'refund-half-one' }, + actor, + ); + await harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '50.00', idempotencyKey: 'refund-half-two' }, + actor, + ); + + expect(harness.state.resolutions.filter((row) => row.status === 'completed')).toHaveLength(2); + expect(harness.state.payments.filter((row) => row.originalPaymentId === PAYMENT_ID)) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ amount: '-50.00' }), + expect.objectContaining({ amount: '-50.00' }), + ])); + }); + + it('uses the freshly locked folio when acceptance completes during refund I/O', async () => { + const harness = makeHarness({ + payments: [capturedPayment({ + method: 'credit_card', + idempotencyKey: 'booking-request-charge:original', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_original', + })], + }); + let release!: (value: { success: true; transactionId: string }) => void; + harness.refundGateway.refund.mockImplementation(() => new Promise((resolve) => { + release = resolve; + })); + + const refunding = harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '25.00', idempotencyKey: 'accept-during-refund' }, + actor, + ); + await vi.waitFor(() => expect(harness.refundGateway.refund).toHaveBeenCalledTimes(1)); + Object.assign(harness.state.requests[0], { + status: 'accepted', + acceptedTotal: '220.00', + acceptedFolioId: FOLIO_ID, + }); + harness.state.payments[0]!.folioId = FOLIO_ID; + release({ success: true, transactionId: 're_after_accept' }); + + const result = await refunding; + expect(result.movement).toMatchObject({ folioId: FOLIO_ID, amount: '-25.00' }); + expect(harness.folioService.recalculateBalance).toHaveBeenCalledWith( + FOLIO_ID, + PROPERTY_ID, + expect.anything(), + ); + }); + + it('heals allocations when a concurrent webhook completes the refund during provider I/O', async () => { + const movementId = 'dddddddd-0000-4000-a000-000000000077'; + const harness = makeHarness({ + installments: [installment({ allocatedAmount: '100.00', status: 'paid' })], + allocations: [{ + id: '88888888-0000-4000-a000-000000000077', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + installmentId: INSTALLMENT_ID, + amount: '100.00', + createdAt: new Date('2026-08-20T10:00:00.000Z'), + }], + payments: [capturedPayment({ + amount: '100.00', + method: 'credit_card', + idempotencyKey: 'booking-request-charge:original', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_original', + })], + }); + let release!: (value: { success: true; transactionId: string }) => void; + harness.refundGateway.refund.mockImplementation(() => new Promise((resolve) => { + release = resolve; + })); + + const refunding = harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '40.00', idempotencyKey: 'webhook-wins-refund-race' }, + actor, + ); + await vi.waitFor(() => expect(harness.refundGateway.refund).toHaveBeenCalledTimes(1)); + harness.state.payments.push(capturedPayment({ + id: movementId, + amount: '-40.00', + originalPaymentId: PAYMENT_ID, + idempotencyKey: 'booking-request-refund:webhook-wins-refund-race', + gatewayTransactionId: 're_webhook_won', + })); + Object.assign(harness.state.resolutions[0]!, { + status: 'completed', + movementId, + providerTransactionId: 're_webhook_won', + providerStatus: 'succeeded', + resolvedAt: new Date(), + }); + release({ success: true, transactionId: 're_webhook_won' }); + + await expect(refunding).resolves.toMatchObject({ + movement: { id: movementId }, + resolution: { status: 'completed' }, + }); + expect(harness.state.allocations).toEqual([ + expect.objectContaining({ amount: '60.00' }), + ]); + expect(harness.state.installments[0]).toMatchObject({ + allocatedAmount: '60.00', status: 'partial', + }); + }); + + it('keeps a captured provider refund claim retryable when folio recalculation rolls back', async () => { + const harness = makeHarness({ + requests: [request({ + status: 'accepted', + acceptedTotal: '220.00', + acceptedFolioId: FOLIO_ID, + })], + payments: [capturedPayment({ + folioId: FOLIO_ID, + method: 'credit_card', + idempotencyKey: 'booking-request-charge:original', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_original', + })], + }); + harness.folioService.recalculateBalance + .mockRejectedValueOnce(new Error('folio lock timeout')) + .mockResolvedValueOnce(undefined); + + await expect(harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '25.00', idempotencyKey: 'refund-recalc-recovery' }, + actor, + )).rejects.toThrow(/folio lock timeout/i); + expect(harness.state.resolutions).toEqual([ + expect.objectContaining({ status: 'pending', amount: '25.00' }), + ]); + expect(harness.state.payments).toHaveLength(1); + + const recovered = await harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '25.00', idempotencyKey: 'refund-recalc-recovery' }, + actor, + ); + expect(recovered.resolution).toMatchObject({ status: 'completed' }); + const keys = harness.refundGateway.refund.mock.calls.map((call) => call[2].idempotencyKey); + expect(new Set(keys).size).toBe(1); + }); + + it('replays a fully completed refund before remaining-capacity validation', async () => { + const original = capturedPayment({ + method: 'credit_card', + idempotencyKey: 'booking-request-charge:original', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_original', + }); + const harness = makeHarness({ payments: [original] }); + + const first = await harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '100.00', idempotencyKey: 'full-refund' }, + actor, + ); + const replay = await harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '100.00', idempotencyKey: 'full-refund' }, + actor, + ); + expect(replay.movement.id).toBe(first.movement.id); + expect(harness.refundGateway.refund).toHaveBeenCalledTimes(1); + await expect(harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '99.00', idempotencyKey: 'full-refund' }, + actor, + )).rejects.toThrow(/different/i); + }); + + it('heals stale paid allocations when a completed refund is replayed', async () => { + const harness = makeHarness({ + installments: [installment({ + resolvedAmount: '100.00', allocatedAmount: '0.00', status: 'unpaid', + })], + payments: [capturedPayment({ + amount: '100.00', + method: 'credit_card', + idempotencyKey: 'booking-request-charge:original', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_original', + })], + }); + const input = { amount: '40.00', idempotencyKey: 'refund-heals-stale-allocation' }; + await harness.service.refund(REQUEST_ID, PAYMENT_ID, PROPERTY_ID, input, actor); + Object.assign(harness.state.installments[0]!, { + allocatedAmount: '100.00', status: 'paid', + }); + harness.state.allocations.push({ + id: '88888888-0000-4000-a000-000000000001', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + installmentId: INSTALLMENT_ID, + amount: '100.00', + createdAt: new Date('2026-08-20T10:00:00.000Z'), + }); + + await harness.service.refund(REQUEST_ID, PAYMENT_ID, PROPERTY_ID, input, actor); + + expect(harness.state.allocations).toEqual([ + expect.objectContaining({ amount: '60.00' }), + ]); + expect(harness.state.installments).toEqual([ + expect.objectContaining({ allocatedAmount: '60.00', status: 'partial' }), + ]); + expect(harness.refundGateway.refund).toHaveBeenCalledTimes(1); + }); + + it('does not send an externally recorded payment to the configured gateway refund adapter', async () => { + const harness = makeHarness({ + payments: [capturedPayment({ method: 'credit_card', gatewayProvider: 'square' })], + }); + await expect(harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '10.00', idempotencyKey: 'wrong-refund-path' }, + actor, + )).rejects.toThrow(/external return/i); + expect(harness.refundGateway.refund).not.toHaveBeenCalled(); + }); + + it('records partial external returns as negative canonical movements', async () => { + const harness = makeHarness({ payments: [capturedPayment()] }); + const result = await harness.service.recordExternalReturn( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { + amount: '30.00', + processedAt: '2026-08-21T10:00:00.000Z', + reference: 'return-1', + notes: 'Returned by bank transfer', + }, + actor, + ); + expect(result.movement).toMatchObject({ + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + originalPaymentId: PAYMENT_ID, + amount: '-30.00', + status: 'captured', + reference: 'return-1', + }); + expect(result.resolution).toMatchObject({ + paymentId: PAYMENT_ID, + type: 'external_return', + amount: '30.00', + movementId: result.movement.id, + }); + expect(harness.state.consequences).toEqual([ + expect.objectContaining({ + kind: expect.stringMatching(/^payment_refunded:/), + payload: expect.objectContaining({ + event: 'payment.refunded', + data: expect.objectContaining({ source: 'external_return', method: 'cash' }), + }), + }), + ]); + const event = harness.state.consequences[0]?.['payload']?.['event'] as WebhookEvent; + expect(WEBHOOK_EVENTS[event]).toBe(event); + expect(harness.state.emails).toEqual([ + expect.objectContaining({ kind: 'refund', logicalKey: expect.stringMatching(/^refund:/) }), + ]); + expect(JSON.stringify(harness.state.emails[0])).not.toContain('return-1'); + }); + + it('fingerprints the complete external-return record for exact replay', async () => { + const harness = makeHarness({ payments: [capturedPayment()] }); + const input = { + amount: '30.00', + processedAt: '2026-08-21T10:00:00.000Z', + reference: 'return-fingerprint', + notes: 'Returned at bank', + }; + const first = await harness.service.recordExternalReturn( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + input, + actor, + ); + const replay = await harness.service.recordExternalReturn( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + input, + actor, + ); + expect(replay.movement.id).toBe(first.movement.id); + + for (const changed of [ + { ...input, processedAt: '2026-08-21T11:00:00.000Z' }, + { ...input, notes: 'Different note' }, + ]) { + await expect(harness.service.recordExternalReturn( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + changed, + actor, + )).rejects.toThrow(/reference|different financial data/i); + } + }); + + it('repairs a missed folio recalculation on an exact external-return replay', async () => { + const harness = makeHarness({ payments: [capturedPayment()] }); + const input = { + amount: '30.00', + processedAt: '2026-08-21T10:00:00.000Z', + reference: 'return-recalc-replay', + }; + await harness.service.recordExternalReturn( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + input, + actor, + ); + Object.assign(harness.state.requests[0], { + status: 'accepted', + acceptedTotal: '220.00', + acceptedFolioId: FOLIO_ID, + }); + for (const payment of harness.state.payments) payment.folioId = FOLIO_ID; + + await harness.service.recordExternalReturn( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + input, + actor, + ); + expect(harness.folioService.recalculateBalance).toHaveBeenCalledWith( + FOLIO_ID, + PROPERTY_ID, + expect.anything(), + ); + }); + + it('heals stale paid allocations when a completed external return is replayed', async () => { + const harness = makeHarness({ + installments: [installment({ + resolvedAmount: '100.00', allocatedAmount: '0.00', status: 'unpaid', + })], + payments: [capturedPayment({ amount: '100.00' })], + }); + const input = { + amount: '40.00', + processedAt: '2026-08-21T10:00:00.000Z', + reference: 'return-heals-stale-allocation', + }; + await harness.service.recordExternalReturn( + REQUEST_ID, PAYMENT_ID, PROPERTY_ID, input, actor, + ); + Object.assign(harness.state.installments[0]!, { + allocatedAmount: '100.00', status: 'paid', + }); + harness.state.allocations.push({ + id: '88888888-0000-4000-a000-000000000001', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + installmentId: INSTALLMENT_ID, + amount: '100.00', + createdAt: new Date('2026-08-20T10:00:00.000Z'), + }); + + await harness.service.recordExternalReturn( + REQUEST_ID, PAYMENT_ID, PROPERTY_ID, input, actor, + ); + + expect(harness.state.allocations).toEqual([ + expect.objectContaining({ amount: '60.00' }), + ]); + expect(harness.state.installments).toEqual([ + expect.objectContaining({ allocatedAmount: '60.00', status: 'partial' }), + ]); + }); + + it('releases over-allocated value and recomputes installment state after a return', async () => { + const harness = makeHarness({ + installments: [installment({ + resolvedAmount: '100.00', + allocatedAmount: '80.00', + status: 'partial', + })], + payments: [capturedPayment({ amount: '100.00' })], + allocations: [{ + id: '88888888-0000-4000-a000-000000000001', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + installmentId: INSTALLMENT_ID, + amount: '80.00', + createdAt: new Date('2026-08-20T10:00:00.000Z'), + }], + }); + + await harness.service.recordExternalReturn( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { + amount: '40.00', + processedAt: '2026-08-21T10:00:00.000Z', + reference: 'return-releases-allocation', + }, + actor, + ); + + expect(harness.state.allocations).toEqual([ + expect.objectContaining({ amount: '60.00' }), + ]); + expect(harness.state.installments).toEqual([ + expect.objectContaining({ allocatedAmount: '60.00', status: 'partial' }), + ]); + }); + + it('requires a reason for retained money and supports partial retained resolution', async () => { + const harness = makeHarness({ payments: [capturedPayment()] }); + await expect(harness.service.retainForDenial( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '20.00', reason: ' ' }, + actor, + )).rejects.toThrow(/reason/i); + + const retained = await harness.service.retainForDenial( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '20.00', reason: 'Non-refundable supplier cost' }, + actor, + ); + expect(retained).toMatchObject({ + paymentId: PAYMENT_ID, + type: 'retained', + amount: '20.00', + reason: 'Non-refundable supplier cost', + resolvedBy: actor.userId, + }); + expect(harness.state.consequences).toHaveLength(0); + expect(harness.state.emails).toHaveLength(0); + }); + + it('allows retention only while the request decision is pending', async () => { + for (const status of ['accepted', 'denied'] as const) { + const harness = makeHarness({ + requests: [request({ + status, + acceptedTotal: status === 'accepted' ? '220.00' : null, + acceptedFolioId: status === 'accepted' ? FOLIO_ID : null, + })], + payments: [capturedPayment({ + folioId: status === 'accepted' ? FOLIO_ID : null, + })], + }); + await expect(harness.service.retainForDenial( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '20.00', reason: 'Supplier cost' }, + actor, + )).rejects.toThrow(/pending request/i); + expect(harness.state.resolutions).toHaveLength(0); + } + }); + + it('rejects zero/negative, future-dated, wrong-currency, over-resolved, and cross-property movements', async () => { + const harness = makeHarness({ + payments: [capturedPayment()], + resolutions: [{ + id: '00000000-0000-4000-a000-000000000001', + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + type: 'retained', + amount: '90.00', + reason: 'Existing resolution', + }], + }); + await expect(harness.service.recordExternalPayment(REQUEST_ID, PROPERTY_ID, { + amount: '10.00', + currencyCode: 'USD', + method: 'cash', + processedAt: '2026-08-20T10:00:00.000Z', + reference: 'wrong-currency', + }, actor)).rejects.toThrow(/currency/i); + await expect(harness.service.recordExternalPayment(REQUEST_ID, PROPERTY_ID, { + amount: '10.00', + currencyCode: 'EUR', + method: 'cash', + processedAt: new Date(Date.now() + 86_400_000).toISOString(), + reference: 'future', + }, actor)).rejects.toThrow(/future/i); + await expect(harness.service.retainForDenial( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '20.00', reason: 'Too much' }, + actor, + )).rejects.toThrow(/remaining/i); + + const crossProperty = makeHarness({ + requests: [request({ propertyId: OTHER_PROPERTY_ID })], + payments: [capturedPayment({ propertyId: OTHER_PROPERTY_ID })], + }); + await expect(crossProperty.service.recordExternalReturn( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { + amount: '10.00', + processedAt: '2026-08-20T10:00:00.000Z', + reference: 'cross-property', + }, + actor, + )).rejects.toBeInstanceOf(NotFoundException); + }); + + it('audits durable installment, payment, allocation, return, and retention consequences', async () => { + const harness = makeHarness({ + installments: [installment()], + payments: [capturedPayment()], + }); + await harness.service.allocatePayment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { paymentId: PAYMENT_ID, amount: '10.00' }, + actor, + ); + await harness.service.retainForDenial( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '10.00', reason: 'Supplier fee' }, + actor, + ); + expect(harness.state.audits).toEqual(expect.arrayContaining([ + expect.objectContaining({ + entityType: 'booking_request_payment_allocation', + userId: actor.userId, + }), + expect.objectContaining({ + entityType: 'booking_request_payment_resolution', + userId: actor.userId, + }), + ])); + expect(harness.state.audits.every((entry) => + entry['bookingRequestId'] === REQUEST_ID)).toBe(true); + }); +}); diff --git a/apps/api/src/modules/booking-request/booking-request-pricing.spec.ts b/apps/api/src/modules/booking-request/booking-request-pricing.spec.ts new file mode 100644 index 00000000..d94b994a --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-pricing.spec.ts @@ -0,0 +1,182 @@ +import { ConflictException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { buildAcceptedPricingSnapshot } from './booking-request-pricing'; + +const submitted = { + currencyCode: 'EUR', + grandTotal: '240.00', + roomTotal: '200.00', + taxTotal: '20.00', + lineItems: [ + { date: '2026-10-01', rate: '100.00', tax: '10.00' }, + { date: '2026-10-02', rate: '100.00', tax: '10.00' }, + ], + servicesTotal: '18.00', + servicesTaxTotal: '2.00', + services: [{ + serviceId: 'svc-breakfast', + code: 'BREAKFAST', + name: 'Breakfast', + postingRule: 'once', + chargeType: 'food_beverage', + currencyCode: 'EUR', + unitPrice: '18.00', + quantity: 1, + lineTotal: '18.00', + taxTotal: '2.00', + lineItems: [{ date: '2026-10-01', amount: '18.00', tax: '2.00' }], + }], +}; + +const current = { + ...structuredClone(submitted), + grandTotal: '294.00', + roomTotal: '240.00', + lineItems: [ + { date: '2026-10-01', rate: '120.00', tax: '10.00' }, + { date: '2026-10-02', rate: '120.00', tax: '10.00' }, + ], + servicesTotal: '30.00', + servicesTaxTotal: '4.00', + services: [{ + ...structuredClone(submitted.services[0]), + postingRule: 'per_night', + unitPrice: '15.00', + quantity: 2, + lineTotal: '30.00', + taxTotal: '4.00', + lineItems: [ + { date: '2026-10-01', amount: '15.00', tax: '2.00' }, + { date: '2026-10-02', amount: '15.00', tax: '2.00' }, + ], + }], +}; + +describe('buildAcceptedPricingSnapshot', () => { + it('freezes the selected current room, tax, and service components', () => { + const snapshot = buildAcceptedPricingSnapshot({ + source: 'current', + requestCurrencyCode: 'EUR', + submittedQuote: submitted, + currentQuote: current, + }); + + expect(snapshot).toEqual({ + version: 1, + source: 'current', + currencyCode: 'EUR', + grandTotal: '294.00', + roomTotal: '240.00', + taxTotal: '20.00', + nights: [ + { date: '2026-10-01', roomAmount: '120.00', taxAmount: '10.00' }, + { date: '2026-10-02', roomAmount: '120.00', taxAmount: '10.00' }, + ], + servicesTotal: '30.00', + servicesTaxTotal: '4.00', + services: [{ + serviceId: 'svc-breakfast', + code: 'BREAKFAST', + name: 'Breakfast', + postingRule: 'per_night', + chargeType: 'food_beverage', + currencyCode: 'EUR', + unitPrice: '15.00', + quantity: 2, + lineTotal: '30.00', + taxTotal: '4.00', + lineItems: [ + { date: '2026-10-01', amount: '15.00', taxAmount: '2.00' }, + { date: '2026-10-02', amount: '15.00', taxAmount: '2.00' }, + ], + }], + customReason: null, + adjustment: null, + }); + }); + + it('keeps quoted components and records one deterministic custom adjustment', () => { + const snapshot = buildAcceptedPricingSnapshot({ + source: 'custom', + requestCurrencyCode: 'EUR', + submittedQuote: submitted, + currentQuote: current, + customTotal: '250.00', + customReason: 'Loyalty recovery', + }); + + expect(snapshot.grandTotal).toBe('250.00'); + expect(snapshot.adjustment).toEqual({ + amount: '-44.00', + reason: 'Loyalty recovery', + serviceDate: '2026-10-01', + }); + expect(snapshot.nights[0]?.roomAmount).toBe('120.00'); + expect(snapshot.services[0]?.lineTotal).toBe('30.00'); + }); + + it('preserves the mandatory custom reason when the custom total equals current', () => { + const snapshot = buildAcceptedPricingSnapshot({ + source: 'custom', + requestCurrencyCode: 'EUR', + submittedQuote: submitted, + currentQuote: current, + customTotal: '294.00', + customReason: 'Matched a written offer', + }); + + expect(snapshot).toMatchObject({ + source: 'custom', + grandTotal: '294.00', + customReason: 'Matched a written offer', + adjustment: null, + }); + }); + + it('rejects a quote currency that differs from the request currency', () => { + expect(() => buildAcceptedPricingSnapshot({ + source: 'submitted', + requestCurrencyCode: 'USD', + submittedQuote: submitted, + currentQuote: current, + })).toThrow(ConflictException); + }); + + it('rejects component totals that do not equal their immutable posting lines', () => { + const incoherent = { + ...structuredClone(current), + roomTotal: '241.00', + grandTotal: '295.00', + }; + + expect(() => buildAcceptedPricingSnapshot({ + source: 'current', + requestCurrencyCode: 'EUR', + submittedQuote: submitted, + currentQuote: incoherent, + })).toThrow(/nightly room/i); + }); + + it.each([ + ['JPY', '120.5'], + ['USD', '120.001'], + ])('rejects a %s authoritative quote amount with fractional minor units', ( + currencyCode, + rate, + ) => { + const submittedQuote = structuredClone(submitted); + submittedQuote.currencyCode = currencyCode; + submittedQuote.services[0]!.currencyCode = currencyCode; + const currentQuote = structuredClone(current); + currentQuote.currencyCode = currencyCode; + currentQuote.services[0]!.currencyCode = currencyCode; + currentQuote.lineItems[0]!.rate = rate; + + expect(() => buildAcceptedPricingSnapshot({ + source: 'current', + requestCurrencyCode: currencyCode, + submittedQuote, + currentQuote, + })).toThrow(new RegExp(`fractional minor units.*${currencyCode}`, 'i')); + }); +}); diff --git a/apps/api/src/modules/booking-request/booking-request-pricing.ts b/apps/api/src/modules/booking-request/booking-request-pricing.ts new file mode 100644 index 00000000..7c8fa54b --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-pricing.ts @@ -0,0 +1,276 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import type { AcceptedPricingSnapshot } from './booking-request-db.js'; +import Decimal from 'decimal.js'; +import { + assertLedgerCurrencySupported, + type BookingRequestPriceSource, +} from './booking-request-money'; + +type QuoteRecord = Record; + +export interface BuildAcceptedPricingInput { + source: BookingRequestPriceSource; + requestCurrencyCode: string; + submittedQuote: unknown; + currentQuote: unknown; + customTotal?: string; + customReason?: string; +} + +function object(value: unknown, label: string): QuoteRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ConflictException(`${label} is not a valid quote snapshot`); + } + return value as QuoteRecord; +} + +function string(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new ConflictException(`${label} is missing from the quote snapshot`); + } + return value; +} + +function money( + value: unknown, + label: string, + currencyCode: string, + currencyExponent: number, +): Decimal { + const raw = string(value, label); + let parsed: Decimal; + try { + parsed = new Decimal(raw); + if (!parsed.isFinite() || parsed.isNegative()) throw new Error('invalid'); + } catch { + throw new ConflictException(`${label} is not valid money`); + } + if (parsed.decimalPlaces() > currencyExponent) { + throw new ConflictException( + `${label} has fractional minor units for ${currencyCode}`, + ); + } + return parsed; +} + +function integer(value: unknown, label: string): number { + if (!Number.isInteger(value) || Number(value) < 0) { + throw new ConflictException(`${label} is not a valid quantity`); + } + return Number(value); +} + +function assertCurrency( + quote: QuoteRecord, + requestCurrencyCode: string, + label: string, +): void { + const currency = string(quote['currencyCode'], `${label} currency`); + if (currency !== requestCurrencyCode) { + throw new ConflictException( + `${label} currency ${currency} does not match request currency ${requestCurrencyCode}`, + ); + } +} + +function normalizeQuote( + quote: QuoteRecord, + source: BookingRequestPriceSource, + requestCurrencyCode: string, + currencyExponent: number, +): Omit< + AcceptedPricingSnapshot, + 'version' | 'source' | 'customReason' | 'adjustment' +> { + const rawNights = quote['lineItems']; + if (!Array.isArray(rawNights) || rawNights.length === 0) { + throw new ConflictException('Accepted quote has no nightly pricing'); + } + const seenDates = new Set(); + const nights = rawNights.map((raw, index) => { + const row = object(raw, `Night ${index + 1}`); + const date = string(row['date'], `Night ${index + 1} date`); + if (seenDates.has(date)) { + throw new ConflictException(`Accepted quote repeats night ${date}`); + } + seenDates.add(date); + return { + date, + roomAmount: money(row['rate'], `Night ${date} room amount`, requestCurrencyCode, currencyExponent).toFixed(2), + taxAmount: money(row['tax'], `Night ${date} tax amount`, requestCurrencyCode, currencyExponent).toFixed(2), + }; + }); + + const rawServices = quote['services']; + if (!Array.isArray(rawServices)) { + throw new ConflictException('Accepted quote services are invalid'); + } + const services = rawServices.map((raw, index) => { + const row = object(raw, `Service ${index + 1}`); + const currencyCode = string(row['currencyCode'], `Service ${index + 1} currency`); + if (currencyCode !== requestCurrencyCode) { + throw new ConflictException( + `Service currency ${currencyCode} does not match request currency ${requestCurrencyCode}`, + ); + } + const rawLineItems = row['lineItems']; + if (!Array.isArray(rawLineItems)) { + throw new ConflictException(`Service ${index + 1} line items are invalid`); + } + return { + serviceId: string(row['serviceId'], `Service ${index + 1} id`), + code: string(row['code'], `Service ${index + 1} code`), + name: string(row['name'], `Service ${index + 1} name`), + postingRule: string(row['postingRule'], `Service ${index + 1} posting rule`), + chargeType: string(row['chargeType'], `Service ${index + 1} charge type`), + currencyCode, + unitPrice: money(row['unitPrice'], `Service ${index + 1} unit price`, requestCurrencyCode, currencyExponent).toFixed(2), + quantity: integer(row['quantity'], `Service ${index + 1} quantity`), + lineTotal: money(row['lineTotal'], `Service ${index + 1} total`, requestCurrencyCode, currencyExponent).toFixed(2), + taxTotal: money(row['taxTotal'], `Service ${index + 1} tax`, requestCurrencyCode, currencyExponent).toFixed(2), + lineItems: rawLineItems.map((rawLine, lineIndex) => { + const line = object(rawLine, `Service ${index + 1} line ${lineIndex + 1}`); + return { + date: string(line['date'], `Service ${index + 1} line date`), + amount: money(line['amount'], `Service ${index + 1} line amount`, requestCurrencyCode, currencyExponent).toFixed(2), + taxAmount: money(line['tax'], `Service ${index + 1} line tax`, requestCurrencyCode, currencyExponent).toFixed(2), + }; + }), + }; + }); + + const roomTotal = money(quote['roomTotal'], 'Room total', requestCurrencyCode, currencyExponent); + const taxTotal = money(quote['taxTotal'], 'Room tax total', requestCurrencyCode, currencyExponent); + const servicesTotal = money(quote['servicesTotal'], 'Services total', requestCurrencyCode, currencyExponent); + const servicesTaxTotal = money(quote['servicesTaxTotal'], 'Services tax total', requestCurrencyCode, currencyExponent); + const grandTotal = money(quote['grandTotal'], 'Grand total', requestCurrencyCode, currencyExponent); + const nightlyRoomTotal = nights.reduce( + (sum, night) => sum.plus(night.roomAmount), + new Decimal(0), + ); + const nightlyTaxTotal = nights.reduce( + (sum, night) => sum.plus(night.taxAmount), + new Decimal(0), + ); + if (!nightlyRoomTotal.equals(roomTotal)) { + throw new ConflictException('Accepted nightly room lines do not equal room total'); + } + if (!nightlyTaxTotal.equals(taxTotal)) { + throw new ConflictException('Accepted nightly tax lines do not equal room tax total'); + } + for (const service of services) { + const serviceLineTotal = service.lineItems.reduce( + (sum, line) => sum.plus(line.amount), + new Decimal(0), + ); + const serviceLineTax = service.lineItems.reduce( + (sum, line) => sum.plus(line.taxAmount), + new Decimal(0), + ); + if (!serviceLineTotal.equals(service.lineTotal)) { + throw new ConflictException( + `Accepted service ${service.code} lines do not equal its total`, + ); + } + if (!serviceLineTax.equals(service.taxTotal)) { + throw new ConflictException( + `Accepted service ${service.code} tax lines do not equal its tax total`, + ); + } + } + const serviceComponentTotal = services.reduce( + (sum, service) => sum.plus(service.lineTotal), + new Decimal(0), + ); + const serviceComponentTax = services.reduce( + (sum, service) => sum.plus(service.taxTotal), + new Decimal(0), + ); + if (!serviceComponentTotal.equals(servicesTotal)) { + throw new ConflictException('Accepted service lines do not equal services total'); + } + if (!serviceComponentTax.equals(servicesTaxTotal)) { + throw new ConflictException('Accepted service tax lines do not equal services tax total'); + } + const componentsTotal = roomTotal.plus(taxTotal).plus(servicesTotal).plus(servicesTaxTotal); + if (!componentsTotal.equals(grandTotal)) { + throw new ConflictException( + `${source} quote components do not equal its grand total`, + ); + } + + return { + currencyCode: requestCurrencyCode, + grandTotal: grandTotal.toFixed(2), + roomTotal: roomTotal.toFixed(2), + taxTotal: taxTotal.toFixed(2), + nights, + services, + servicesTotal: servicesTotal.toFixed(2), + servicesTaxTotal: servicesTaxTotal.toFixed(2), + }; +} + +export function buildAcceptedPricingSnapshot( + input: BuildAcceptedPricingInput, +): AcceptedPricingSnapshot { + const exponent = assertLedgerCurrencySupported( + input.requestCurrencyCode, + (message) => new ConflictException(message), + ); + const submitted = object(input.submittedQuote, 'Submitted quote'); + const current = object(input.currentQuote, 'Current quote'); + assertCurrency(submitted, input.requestCurrencyCode, 'Submitted quote'); + assertCurrency(current, input.requestCurrencyCode, 'Current quote'); + + const basis = input.source === 'submitted' ? submitted : current; + const normalized = normalizeQuote( + basis, + input.source, + input.requestCurrencyCode, + exponent, + ); + if (input.source !== 'custom') { + return { + version: 1, + source: input.source, + ...normalized, + customReason: null, + adjustment: null, + }; + } + + const reason = input.customReason?.trim(); + if (!reason) { + throw new BadRequestException('A reason is required for a custom accepted price'); + } + let custom: Decimal; + try { + custom = new Decimal(input.customTotal ?? ''); + } catch { + throw new BadRequestException('Custom accepted total must be valid money'); + } + if (!custom.isFinite() || custom.lessThanOrEqualTo(0)) { + throw new BadRequestException('Custom accepted total must be greater than zero'); + } + if (custom.decimalPlaces() > exponent) { + throw new BadRequestException( + `Custom accepted total has fractional minor units for ${input.requestCurrencyCode.toUpperCase()}`, + ); + } + const adjustment = custom.minus(normalized['grandTotal']).toDecimalPlaces(2); + return { + version: 1, + source: 'custom', + ...normalized, + grandTotal: custom.toFixed(2), + customReason: reason, + adjustment: adjustment.isZero() + ? null + : { + amount: adjustment.toFixed(2), + reason, + serviceDate: normalized['nights'][0]!.date, + }, + }; +} diff --git a/apps/api/src/modules/booking-request/booking-request-public.controller.ts b/apps/api/src/modules/booking-request/booking-request-public.controller.ts new file mode 100644 index 00000000..169b731f --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-public.controller.ts @@ -0,0 +1,43 @@ +import { Body, Controller, Inject, Post, Req, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { BookingEngineScopeGuard } from '../auth/booking-engine-scope.guard'; +import { BookingKeyGuard } from '../auth/booking-key.guard'; +import { Public } from '../auth/public.decorator'; +import { BookingThrottleGuard } from '../booking-engine/booking-throttle.guard'; +import { BookingRequestService } from './booking-request.service'; +// DTO classes must remain runtime imports for Nest validation metadata. +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { CreateRequestCardSetupDto } from './dto/create-request-card-setup.dto'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { SubmitBookingRequestDto } from './dto/submit-booking-request.dto'; + +type BookingEngineRequest = { + bookingEngine: { propertyId: string }; +}; + +@ApiTags('Booking Engine — Booking Requests') +@ApiSecurity('booking-key') +@Controller('booking-engine') +@Public() +@UseGuards(BookingKeyGuard, BookingEngineScopeGuard) +export class BookingRequestPublicController { + constructor( + @Inject(BookingRequestService) private readonly service: BookingRequestService, + ) {} + + @Post('request-payment-method-setup') + @UseGuards(BookingThrottleGuard) + @ApiOperation({ summary: 'Prepare optional or required request card collection' }) + @ApiResponse({ status: 201, description: 'SetupIntent client details' }) + createSetup(@Body() dto: CreateRequestCardSetupDto, @Req() req: BookingEngineRequest) { + return this.service.createPaymentMethodSetup(req.bookingEngine.propertyId, dto); + } + + @Post('requests') + @UseGuards(BookingThrottleGuard) + @ApiOperation({ summary: 'Submit a sellable stay for staff review' }) + @ApiResponse({ status: 201, description: 'Pending request acknowledgement' }) + submit(@Body() dto: SubmitBookingRequestDto, @Req() req: BookingEngineRequest) { + return this.service.submit(req.bookingEngine.propertyId, dto); + } +} diff --git a/apps/api/src/modules/booking-request/booking-request-state.spec.ts b/apps/api/src/modules/booking-request/booking-request-state.spec.ts new file mode 100644 index 00000000..caf94969 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-state.spec.ts @@ -0,0 +1,22 @@ +import { ConflictException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { + assertBookingRequestTransition, + type BookingRequestStatus, +} from './booking-request-state'; + +describe('booking request state', () => { + it('allows only pending to accepted or denied', () => { + expect(() => assertBookingRequestTransition('pending', 'accepted')).not.toThrow(); + expect(() => assertBookingRequestTransition('pending', 'denied')).not.toThrow(); + expect(() => assertBookingRequestTransition('accepted', 'denied')).toThrow(/accepted/); + }); + + it('rejects every transition from a terminal status with a conflict', () => { + const statuses: BookingRequestStatus[] = ['accepted', 'denied']; + for (const from of statuses) { + expect(() => assertBookingRequestTransition(from, 'accepted')).toThrow(ConflictException); + expect(() => assertBookingRequestTransition(from, 'denied')).toThrow(ConflictException); + } + }); +}); diff --git a/apps/api/src/modules/booking-request/booking-request-state.ts b/apps/api/src/modules/booking-request/booking-request-state.ts new file mode 100644 index 00000000..95db3de6 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-state.ts @@ -0,0 +1,18 @@ +import { ConflictException } from '@nestjs/common'; + +export type BookingRequestStatus = 'pending' | 'accepted' | 'denied'; + +/** + * Booking Requests have one decision point. Accepted and denied are terminal; + * payment progress is deliberately not part of this state machine. + */ +export function assertBookingRequestTransition( + from: BookingRequestStatus, + to: Exclude, +): void { + if (from !== 'pending') { + throw new ConflictException( + `Cannot transition booking request from '${from}' to '${to}'`, + ); + } +} diff --git a/apps/api/src/modules/booking-request/booking-request-stripe.handler.ts b/apps/api/src/modules/booking-request/booking-request-stripe.handler.ts new file mode 100644 index 00000000..4fef101e --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-stripe.handler.ts @@ -0,0 +1,923 @@ +import { + Injectable, + Logger, + BadRequestException, + ConflictException, + Inject, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { eq, and } from 'drizzle-orm'; +import { Decimal } from 'decimal.js'; +import { + auditLogs, + bookingRequests, + bookingRequestPaymentResolutions, + payments, +} from './booking-request-db.js'; +import { DRIZZLE } from '../../database/database.module'; +import { WebhookService } from '../webhook/webhook.service'; +import { FolioService } from '../folio/folio.service'; +import type Stripe from 'stripe'; +import { reconcileBookingRequestPaymentAllocations } from './booking-request-allocation-reconciler'; +import { ensureBookingRequestFinancialConsequence } from './booking-request-payment-consequence'; +import { + classifyHaipMetadata, + decidePaymentIntentTransition, + decideRefundTransition, + paymentIntentCorrelation, + refundCorrelation, + type PaymentIntentEvent, + type PaymentIntentCorrelation, + type PaymentIntentLedgerStatus, + type RefundProviderStatus, +} from '../payment/stripe-financial-state'; +import type { + BookingRequestStripeHandler as IBookingRequestStripeHandler, + BookingRequestStripePaymentRow, +} from '../payment/booking-request-stripe-handler.interface'; + +/** + * Stripe webhook handler for booking-request scoped payments. + * Registered when HAIP_BOOKING_REQUESTS=true via BOOKING_REQUEST_STRIPE_HANDLER. + */ +@Injectable() +export class BookingRequestStripeHandler implements IBookingRequestStripeHandler { + private readonly logger = new Logger(BookingRequestStripeHandler.name); + + constructor( + @Inject(DRIZZLE) private readonly db: any, + private readonly webhookService: WebhookService, + private readonly folioService: FolioService, + private readonly configService: ConfigService, + ) {} + + async handlePaymentIntentSucceeded( + pi: Stripe.PaymentIntent, + _payment: BookingRequestStripePaymentRow, + ): Promise { + await this.finalizePaymentIntent(pi, 'succeeded'); + } + + async handlePaymentIntentFailed( + pi: Stripe.PaymentIntent, + _payment: BookingRequestStripePaymentRow, + ): Promise { + await this.finalizePaymentIntent(pi, 'payment_failed'); + } + + async handlePaymentIntentCanceled( + pi: Stripe.PaymentIntent, + _payment: BookingRequestStripePaymentRow, + ): Promise { + await this.finalizePaymentIntent(pi, 'canceled'); + } + + async handlePaymentIntentProcessing( + pi: Stripe.PaymentIntent, + _payment: BookingRequestStripePaymentRow, + ): Promise { + await this.finalizePaymentIntent(pi, 'processing'); + } + + async handlePaymentIntentRequiresAction( + pi: Stripe.PaymentIntent, + _payment: BookingRequestStripePaymentRow, + ): Promise { + await this.finalizePaymentIntent(pi, 'requires_action'); + } + + async handleChargeRefunded( + charge: Stripe.Charge, + _payment: BookingRequestStripePaymentRow, + ): Promise { + await this.handleBookingRequestChargeRefunded(charge); + } + + private async finalizePaymentIntent(pi: Stripe.PaymentIntent, event: PaymentIntentEvent) { + const ownership = classifyHaipMetadata(pi.metadata, paymentIntentCorrelation); + if (ownership.ownership === 'owned-malformed') throw ownership.error; + const correlation = ownership.ownership === 'owned-valid' + ? ownership.correlation + : undefined; + let initial = await this.findPaymentByGatewayTransactionId(pi.id); + const linkedByGatewayTransactionId = initial != null; + if (!initial) { + if (ownership.ownership === 'external') return; + initial = await this.findPaymentByCorrelation(ownership.correlation); + if (!initial) { + throw new ConflictException( + `Stripe PaymentIntent ${pi.id} metadata does not identify a pending payment`, + ); + } + } + + const outcome = await this.db.transaction(async (tx: any) => { + let request: typeof bookingRequests.$inferSelect | undefined; + if (initial.bookingRequestId) { + const requests = await tx + .select() + .from(bookingRequests) + .where(and( + eq(bookingRequests.id, initial.bookingRequestId), + eq(bookingRequests.propertyId, initial.propertyId), + )) + .for('update'); + request = requests.find((row: typeof bookingRequests.$inferSelect) => + row.id === initial.bookingRequestId && row.propertyId === initial.propertyId); + if (!request) { + throw new ConflictException(`Booking request ${initial.bookingRequestId} not found`); + } + } + const lockedRows = await tx + .select() + .from(payments) + .where(and(eq(payments.id, initial.id), eq(payments.propertyId, initial.propertyId))) + .for('update'); + let payment = lockedRows.find((row: typeof payments.$inferSelect) => + row.id === initial.id && row.propertyId === initial.propertyId); + if (!payment) return { changed: false, payment: initial, legacyEvent: undefined }; + + if (correlation) { + if (payment.id !== correlation.paymentId + || payment.propertyId !== correlation.propertyId + || payment.bookingRequestId !== correlation.bookingRequestId + || payment.gatewayProvider !== 'stripe') { + throw new ConflictException('Stripe PaymentIntent metadata ownership is invalid'); + } + if (!linkedByGatewayTransactionId && payment.status !== 'pending') { + throw new ConflictException( + `Stripe PaymentIntent metadata can bind only a pending payment, not '${payment.status}'`, + ); + } + if (!linkedByGatewayTransactionId + && payment.gatewayTransactionId + && payment.gatewayTransactionId !== pi.id) { + throw new ConflictException( + 'Stripe PaymentIntent does not match the provider identity already bound to the payment', + ); + } + } + + if (request) { + try { + this.assertPaymentIntentBindingIdentity(pi, event, payment, request); + } catch (error) { + if (!(error instanceof ConflictException)) throw error; + const reason = error.message; + await this.auditPaymentIntentIdentityMismatch(tx, payment, event, reason); + return { + changed: false, + payment, + identityMismatch: reason, + legacyEvent: undefined, + }; + } + } + + if (correlation) { + if (!payment.gatewayTransactionId) { + const boundRows = await tx + .update(payments) + .set({ gatewayTransactionId: pi.id, updatedAt: new Date() }) + .where(and( + eq(payments.id, payment.id), + eq(payments.propertyId, correlation.propertyId), + eq(payments.bookingRequestId, correlation.bookingRequestId), + eq(payments.status, 'pending'), + )) + .returning(); + const bound = boundRows.find((row: typeof payments.$inferSelect) => row.id === payment!.id); + if (!bound) { + throw new ConflictException('Stripe PaymentIntent payment identity changed while binding'); + } + await tx.insert(auditLogs).values({ + propertyId: payment.propertyId, + action: 'update', + entityType: 'payment', + entityId: payment.id, + previousValue: { + bookingRequestId: payment.bookingRequestId, + gatewayTransactionId: null, + }, + newValue: { + bookingRequestId: payment.bookingRequestId, + gatewayTransactionId: pi.id, + }, + description: 'Stripe PaymentIntent provider identity bound from signed metadata', + }); + payment = bound; + } + } + + if (event === 'succeeded' && request?.status === 'denied' && payment.status !== 'captured') { + await this.auditUnexpectedProviderState(tx, payment, { + stripeObjectId: pi.id, + providerEvent: event, + requestStatus: request.status, + reason: 'Provider reported capture after booking request denial', + }); + return { changed: false, payment, blocked: true, legacyEvent: undefined }; + } + + const decision = decidePaymentIntentTransition( + payment.status as PaymentIntentLedgerStatus, + event, + request?.status, + ); + const folioId = request?.acceptedFolioId ?? payment.folioId; + let current = payment; + let changed = false; + if (decision.action === 'transition') { + const now = new Date(); + const errorMessage = event === 'payment_failed' + ? pi.last_payment_error?.message ?? 'Payment failed' + : event === 'requires_action' + ? 'Payment requires additional authentication; no recovery link is available' + : event === 'canceled' + ? 'Payment canceled by provider' + : null; + const values = { + status: decision.status, + folioId, + processedAt: decision.status === 'captured' ? now : null, + ...(errorMessage ? { notes: errorMessage } : {}), + updatedAt: now, + }; + const updated = await tx + .update(payments) + .set(values) + .where(and( + eq(payments.id, payment.id), + eq(payments.propertyId, payment.propertyId), + eq(payments.status, payment.status), + )) + .returning(); + current = updated.find((row: typeof payments.$inferSelect) => row.id === payment.id) + ?? { ...payment, ...values }; + changed = true; + await tx.insert(auditLogs).values({ + propertyId: payment.propertyId, + action: 'update', + entityType: 'payment', + entityId: payment.id, + previousValue: { status: payment.status, folioId: payment.folioId }, + newValue: { status: decision.status, folioId, stripeObjectId: pi.id }, + description: `Stripe PaymentIntent ${event} finalized monotonically`, + }); + } else if (decision.action === 'unexpected') { + await this.auditUnexpectedProviderState(tx, payment, { + stripeObjectId: pi.id, + providerEvent: event, + currentStatus: payment.status, + }); + } else if (folioId && payment.folioId !== folioId) { + const updated = await tx + .update(payments) + .set({ folioId, updatedAt: new Date() }) + .where(and(eq(payments.id, payment.id), eq(payments.propertyId, payment.propertyId))) + .returning(); + current = updated.find((row: typeof payments.$inferSelect) => row.id === payment.id) + ?? { ...payment, folioId }; + await tx.insert(auditLogs).values({ + propertyId: payment.propertyId, + action: 'update', + entityType: 'payment', + entityId: payment.id, + previousValue: { bookingRequestId: payment.bookingRequestId, folioId: payment.folioId }, + newValue: { bookingRequestId: payment.bookingRequestId, folioId }, + description: 'Stripe PaymentIntent replay repaired accepted folio linkage', + }); + } + + if (request && decision.action !== 'unexpected' && current.status !== 'pending') { + const financialEvent = current.status === 'captured' + ? 'payment.received' as const + : 'payment.failed' as const; + await ensureBookingRequestFinancialConsequence(tx, { + event: financialEvent, + logicalId: current.id, + propertyId: current.propertyId, + bookingRequestId: request.id, + entityType: 'payment', + entityId: current.id, + data: { + folioId, + status: current.status, + amount: current.amount, + currencyCode: current.currencyCode, + }, + }); + } + if (folioId && ( + current.status === 'captured' + || (decision.action === 'repair' && current.status !== 'pending') + )) { + await this.folioService.recalculateBalance(folioId, payment.propertyId, tx); + } + return { + changed, + payment: current, + legacyEvent: request + ? undefined + : current.status === 'captured' + ? 'payment.received' as const + : decision.action === 'transition' + ? 'payment.failed' as const + : undefined, + }; + }); + + if (outcome.blocked) { + throw new ConflictException( + 'Provider captured the payment after booking request denial; operator reconciliation required', + ); + } + if (outcome.identityMismatch) { + throw new ConflictException(outcome.identityMismatch); + } + if (outcome.legacyEvent) { + await this.webhookService.emit( + outcome.legacyEvent, + 'payment', + outcome.payment.id, + { folioId: outcome.payment.folioId, status: outcome.payment.status, stripeEvent: pi.id }, + outcome.payment.propertyId, + ); + } + } + + private async handleBookingRequestChargeRefunded(charge: Stripe.Charge) { + const ownership = classifyHaipMetadata(charge.metadata, paymentIntentCorrelation); + if (ownership.ownership === 'owned-malformed') throw ownership.error; + const correlation = ownership.ownership === 'owned-valid' + ? ownership.correlation + : undefined; + const piId = typeof charge.payment_intent === 'string' + ? charge.payment_intent + : charge.payment_intent?.id; + + if (!piId) { + if (ownership.ownership === 'external') return; + throw new ConflictException( + `Stripe charge ${charge.id} metadata does not identify a linked PaymentIntent`, + ); + } + + const payment = await this.findPaymentByGatewayTransactionId(piId); + if (!payment) { + if (ownership.ownership === 'external') return; + throw new ConflictException( + `Stripe charge ${charge.id} metadata does not identify a linked payment`, + ); + } + + const outcome = await this.db.transaction(async (tx: any) => { + if (payment.bookingRequestId) { + await tx + .select({ id: bookingRequests.id }) + .from(bookingRequests) + .where(and( + eq(bookingRequests.id, payment.bookingRequestId), + eq(bookingRequests.propertyId, payment.propertyId), + )) + .for('update'); + } + const parents = await tx + .select() + .from(payments) + .where( + and( + eq(payments.id, payment.id), + eq(payments.propertyId, payment.propertyId), + ), + ) + .for('update'); + + const parent = parents.find((row: typeof payments.$inferSelect) => + row.id === payment.id && row.propertyId === payment.propertyId); + if (!parent) return; + if (correlation && ( + parent.id !== correlation.paymentId + || parent.propertyId !== correlation.propertyId + || parent.bookingRequestId !== correlation.bookingRequestId + || parent.gatewayProvider !== 'stripe' + )) { + throw new ConflictException('Stripe charge metadata ownership is invalid'); + } + if (parent.bookingRequestId) { + await tx.insert(auditLogs).values({ + propertyId: parent.propertyId, + action: 'update', + entityType: 'payment', + entityId: parent.id, + newValue: { + requestId: parent.bookingRequestId, + paymentId: parent.id, + stripeChargeId: charge.id, + cumulativeRefundMinorUnits: charge.amount_refunded, + }, + description: 'Stripe charge.refunded observed as reconciliation signal only', + }); + await reconcileBookingRequestPaymentAllocations(tx, { + bookingRequestId: parent.bookingRequestId, + propertyId: parent.propertyId, + payment: parent, + }); + const requestFolio = (await tx.select().from(bookingRequests).where(and( + eq(bookingRequests.id, parent.bookingRequestId), + eq(bookingRequests.propertyId, parent.propertyId), + )))[0]?.acceptedFolioId; + const folioId = requestFolio ?? parent.folioId; + if (folioId) { + await this.folioService.recalculateBalance(folioId, parent.propertyId, tx); + } + return { movement: undefined }; + } + + if (charge.currency.trim().toUpperCase() !== parent.currencyCode.trim().toUpperCase()) { + throw new ConflictException('Stripe charge refund currency does not match payment'); + } + const children = await tx + .select() + .from(payments) + .where(and( + eq(payments.propertyId, parent.propertyId), + eq(payments.originalPaymentId, parent.id), + eq(payments.status, 'captured'), + )); + const cumulative = this.fromStripeMinorUnits(charge.amount_refunded, charge.currency); + if (cumulative.gt(new Decimal(parent.amount))) { + throw new ConflictException('Stripe charge refund exceeds captured payment amount'); + } + const alreadyPosted = children.reduce( + (total: Decimal, child: typeof payments.$inferSelect) => + total.plus(new Decimal(child.amount).abs()), + new Decimal(0), + ); + const delta = cumulative.minus(alreadyPosted); + if (delta.lte(0)) return { movement: undefined }; + + const [movement] = await tx.insert(payments).values({ + propertyId: parent.propertyId, + folioId: parent.folioId, + bookingRequestId: null, + idempotencyKey: `stripe-charge-refund:${charge.id}:${charge.amount_refunded}`, + method: parent.method, + status: 'captured', + amount: delta.negated().toFixed(2), + currencyCode: parent.currencyCode, + gatewayProvider: 'stripe', + gatewayTransactionId: `stripe_refund:${charge.id}:${charge.amount_refunded}`, + originalPaymentId: parent.id, + notes: `Stripe charge refund ${charge.id} reconciled at ${charge.amount_refunded}`, + processedAt: new Date(), + }).returning(); + if (!movement) throw new ConflictException('Stripe charge refund movement could not be persisted'); + + const folioId = parent.folioId; + if (folioId) { + await this.folioService.recalculateBalance(folioId, parent.propertyId, tx); + } + return { movement }; + }); + if (outcome?.movement) { + await this.webhookService.emit( + 'payment.refunded', + 'payment', + outcome.movement.id, + { + folioId: outcome.movement.folioId, + originalPaymentId: outcome.movement.originalPaymentId, + refundAmount: new Decimal(outcome.movement.amount).abs().toFixed(2), + }, + outcome.movement.propertyId, + ); + } + } + + async handleRefundUpdated(refund: Stripe.Refund): Promise { + await this.finalizeRefundUpdated(refund); + } + + private async finalizeRefundUpdated(refund: Stripe.Refund) { + const linkedPayment = await this.findPaymentByGatewayTransactionId(refund.id); + const ownership = classifyHaipMetadata(refund.metadata, refundCorrelation); + if (ownership.ownership === 'external') { + if (!linkedPayment) return; + if (!linkedPayment.bookingRequestId) return; + throw new ConflictException( + 'Stripe refund is linked to a payment but missing exact HAIP correlation metadata', + ); + } + if (ownership.ownership === 'owned-malformed') throw ownership.error; + const correlation = ownership.correlation; + const providerStatus = this.refundStatus(refund.status); + const result = await this.db.transaction(async (tx: any) => { + const requestRows = await tx + .select() + .from(bookingRequests) + .where(and( + eq(bookingRequests.id, correlation.bookingRequestId), + eq(bookingRequests.propertyId, correlation.propertyId), + )) + .for('update'); + const request = requestRows.find((row: typeof bookingRequests.$inferSelect) => + row.id === correlation.bookingRequestId && row.propertyId === correlation.propertyId); + if (!request) throw new ConflictException('Stripe refund booking request correlation is invalid'); + + const parentRows = await tx + .select() + .from(payments) + .where(and( + eq(payments.id, correlation.paymentId), + eq(payments.propertyId, correlation.propertyId), + eq(payments.bookingRequestId, correlation.bookingRequestId), + )) + .for('update'); + const parent = parentRows.find((row: typeof payments.$inferSelect) => + row.id === correlation.paymentId + && row.propertyId === correlation.propertyId + && row.bookingRequestId === correlation.bookingRequestId); + if (!parent) throw new ConflictException('Stripe refund payment correlation is invalid'); + + const claimRows = await tx + .select() + .from(bookingRequestPaymentResolutions) + .where(and( + eq(bookingRequestPaymentResolutions.id, correlation.claimId), + eq(bookingRequestPaymentResolutions.propertyId, correlation.propertyId), + eq(bookingRequestPaymentResolutions.bookingRequestId, correlation.bookingRequestId), + eq(bookingRequestPaymentResolutions.paymentId, correlation.paymentId), + )) + .for('update'); + const claim = claimRows.find((row: typeof bookingRequestPaymentResolutions.$inferSelect) => + row.id === correlation.claimId + && row.propertyId === correlation.propertyId + && row.bookingRequestId === correlation.bookingRequestId + && row.paymentId === correlation.paymentId); + if (!claim || claim.type !== 'refund') { + throw new ConflictException('Stripe refund claim correlation is invalid'); + } + if (claim.providerTransactionId && claim.providerTransactionId !== refund.id) { + throw new ConflictException('Stripe refund ID does not match the durable refund claim'); + } + const refundPaymentIntentId = typeof refund.payment_intent === 'string' + ? refund.payment_intent + : refund.payment_intent?.id; + if (refundPaymentIntentId && refundPaymentIntentId !== parent.gatewayTransactionId) { + throw new ConflictException('Stripe refund PaymentIntent does not match the claimed payment'); + } + const amount = this.fromStripeMinorUnits(refund.amount, refund.currency); + if (!amount.eq(claim.amount) + || refund.currency.toUpperCase() !== parent.currencyCode.toUpperCase()) { + throw new ConflictException('Stripe refund amount or currency does not match the claim'); + } + + const decision = decideRefundTransition(claim.status as 'pending' | 'completed' | 'failed', providerStatus); + if (decision.action === 'record_pending') { + await tx.update(bookingRequestPaymentResolutions).set({ + providerTransactionId: refund.id, + providerStatus, + attempts: (claim.attempts ?? 0) + 1, + lastError: `Stripe refund is ${providerStatus}`, + updatedAt: new Date(), + }).where(and( + eq(bookingRequestPaymentResolutions.id, claim.id), + eq(bookingRequestPaymentResolutions.propertyId, claim.propertyId), + eq(bookingRequestPaymentResolutions.status, 'pending'), + )); + await tx.insert(auditLogs).values({ + propertyId: claim.propertyId, + action: 'update', + entityType: 'booking_request_payment_resolution', + entityId: claim.id, + previousValue: { status: claim.status, providerStatus: claim.providerStatus }, + newValue: { status: 'pending', providerStatus, stripeRefundId: refund.id }, + description: 'Stripe refund remains pending under exact durable claim', + }); + return { blocked: false }; + } + if (decision.action === 'unexpected') { + await this.auditUnexpectedRefundState(tx, claim, refund.id, providerStatus); + return { blocked: false }; + } + if (decision.status === 'failed') { + const now = new Date(); + await tx.update(bookingRequestPaymentResolutions).set({ + status: 'failed', + providerTransactionId: refund.id, + providerStatus, + attempts: (claim.attempts ?? 0) + 1, + lastError: refund.failure_reason ?? `Stripe refund ${providerStatus}`, + resolvedAt: now, + updatedAt: now, + }).where(and( + eq(bookingRequestPaymentResolutions.id, claim.id), + eq(bookingRequestPaymentResolutions.propertyId, claim.propertyId), + eq(bookingRequestPaymentResolutions.status, 'pending'), + )); + await tx.insert(auditLogs).values({ + propertyId: claim.propertyId, + action: 'update', + entityType: 'booking_request_payment_resolution', + entityId: claim.id, + previousValue: { status: claim.status, providerStatus: claim.providerStatus }, + newValue: { status: 'failed', providerStatus, stripeRefundId: refund.id }, + description: 'Stripe refund claim failed terminally', + }); + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.failed', + logicalId: claim.id, + propertyId: claim.propertyId, + bookingRequestId: claim.bookingRequestId, + entityType: 'booking_request_payment_resolution', + entityId: claim.id, + data: { + paymentId: parent.id, + type: 'refund', + amount: claim.amount, + currencyCode: parent.currencyCode, + providerStatus, + }, + }); + return { blocked: false }; + } + + if (request.status === 'denied' && decision.action === 'transition') { + await this.auditUnexpectedRefundState(tx, claim, refund.id, providerStatus); + return { blocked: true }; + } + let movement: typeof payments.$inferSelect | undefined; + if (claim.movementId) { + const movementRows = await tx.select().from(payments).where(and( + eq(payments.id, claim.movementId), + eq(payments.propertyId, claim.propertyId), + )); + movement = movementRows.find((row: typeof payments.$inferSelect) => row.id === claim.movementId); + } else { + const providerRows = await tx.select().from(payments).where(and( + eq(payments.propertyId, claim.propertyId), + eq(payments.gatewayTransactionId, refund.id), + )); + movement = providerRows.find((row: typeof payments.$inferSelect) => + row.gatewayTransactionId === refund.id && row.propertyId === claim.propertyId); + } + if (movement && (movement.originalPaymentId !== parent.id + || !new Decimal(movement.amount).abs().eq(claim.amount))) { + throw new ConflictException('Stripe refund ID is already linked to another ledger movement'); + } + if (!movement) { + [movement] = await tx.insert(payments).values({ + propertyId: parent.propertyId, + bookingRequestId: parent.bookingRequestId, + folioId: request.acceptedFolioId, + idempotencyKey: claim.idempotencyKey ?? `booking-request-refund:${claim.id}`, + method: parent.method, + status: 'captured', + amount: amount.negated().toFixed(2), + currencyCode: parent.currencyCode, + gatewayProvider: parent.gatewayProvider, + gatewayTransactionId: refund.id, + originalPaymentId: parent.id, + notes: `Stripe refund ${refund.id}`, + processedAt: new Date(), + }).returning(); + } + if (!movement) throw new ConflictException('Stripe refund movement could not be persisted'); + + if (claim.status === 'pending') { + const now = new Date(); + await tx.update(bookingRequestPaymentResolutions).set({ + status: 'completed', + movementId: movement.id, + providerTransactionId: refund.id, + providerStatus, + reason: `Gateway refund movement ${movement.id}`, + attempts: (claim.attempts ?? 0) + 1, + lastError: null, + resolvedAt: now, + updatedAt: now, + }).where(and( + eq(bookingRequestPaymentResolutions.id, claim.id), + eq(bookingRequestPaymentResolutions.propertyId, claim.propertyId), + eq(bookingRequestPaymentResolutions.status, 'pending'), + )); + } + await tx.insert(auditLogs).values({ + propertyId: parent.propertyId, + action: claim.status === 'pending' ? 'update' : 'create', + entityType: 'booking_request_payment_resolution', + entityId: claim.id, + previousValue: { status: claim.status }, + newValue: { status: 'completed', movementId: movement.id, stripeRefundId: refund.id }, + description: 'Stripe refund finalized by exact durable claim correlation', + }); + await reconcileBookingRequestPaymentAllocations(tx, { + bookingRequestId: parent.bookingRequestId!, + propertyId: parent.propertyId, + payment: parent, + }); + if (request.acceptedFolioId) { + await this.folioService.recalculateBalance(request.acceptedFolioId, parent.propertyId, tx); + } + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.refunded', + logicalId: movement.id, + propertyId: parent.propertyId, + bookingRequestId: parent.bookingRequestId!, + entityType: 'payment', + entityId: movement.id, + data: { + folioId: request.acceptedFolioId, + originalPaymentId: parent.id, + refundAmount: amount.toFixed(2), + currencyCode: parent.currencyCode, + resolutionId: claim.id, + }, + }); + return { blocked: false }; + }); + if (result.blocked) { + throw new ConflictException('Refund succeeded after booking request denial; reconciliation required'); + } + } + + private refundStatus(status: string | null): RefundProviderStatus { + switch (status) { + case 'succeeded': + case 'pending': + case 'requires_action': + case 'failed': + case 'canceled': + return status; + default: + throw new BadRequestException(`Unsupported Stripe refund status '${status ?? 'unknown'}'`); + } + } + + private async auditUnexpectedProviderState( + tx: any, + payment: typeof payments.$inferSelect, + details: Record, + ): Promise { + await tx.insert(auditLogs).values({ + propertyId: payment.propertyId, + action: 'update', + entityType: 'payment', + entityId: payment.id, + previousValue: { status: payment.status }, + newValue: details, + description: 'Unexpected Stripe PaymentIntent state ignored monotonically', + }); + } + + private async auditPaymentIntentIdentityMismatch( + tx: any, + payment: typeof payments.$inferSelect, + event: PaymentIntentEvent, + reason: string, + ): Promise { + await tx.insert(auditLogs).values({ + propertyId: payment.propertyId, + action: 'update', + entityType: 'payment', + entityId: payment.id, + previousValue: { + bookingRequestId: payment.bookingRequestId, + status: payment.status, + folioId: payment.folioId, + }, + newValue: { + bookingRequestId: payment.bookingRequestId, + providerEvent: event, + reason: `PaymentIntent financial/provider identity mismatch: ${reason}`, + }, + description: 'Stripe PaymentIntent identity mismatch rejected without ledger mutation', + }); + } + + private async auditUnexpectedRefundState( + tx: any, + claim: typeof bookingRequestPaymentResolutions.$inferSelect, + refundId: string, + providerStatus: RefundProviderStatus, + ): Promise { + await tx.insert(auditLogs).values({ + propertyId: claim.propertyId, + action: 'update', + entityType: 'booking_request_payment_resolution', + entityId: claim.id, + previousValue: { status: claim.status }, + newValue: { providerStatus, stripeRefundId: refundId }, + description: 'Unexpected Stripe refund state ignored monotonically', + }); + } + + private assertPaymentIntentBindingIdentity( + paymentIntent: Stripe.PaymentIntent, + event: PaymentIntentEvent, + payment: typeof payments.$inferSelect, + request: typeof bookingRequests.$inferSelect | undefined, + ): void { + if (!request) { + throw new ConflictException('Stripe PaymentIntent metadata requires a booking request'); + } + const paymentCurrency = payment.currencyCode.trim().toUpperCase(); + const requestCurrency = request.currencyCode.trim().toUpperCase(); + const providerCurrency = paymentIntent.currency?.trim().toUpperCase(); + if (paymentCurrency !== requestCurrency || providerCurrency !== paymentCurrency) { + throw new ConflictException('Stripe PaymentIntent currency identity does not match'); + } + const expectedAmount = new Decimal(payment.amount); + const configuredAmount = this.fromStripeMinorUnits(paymentIntent.amount, providerCurrency); + if (!configuredAmount.eq(expectedAmount)) { + throw new ConflictException('Stripe PaymentIntent configured amount does not match'); + } + if (event === 'succeeded') { + const receivedAmount = this.fromStripeMinorUnits( + paymentIntent.amount_received, + providerCurrency, + ); + if (!receivedAmount.eq(expectedAmount)) { + throw new ConflictException('Stripe PaymentIntent received amount does not match'); + } + } + const customerId = this.stripeObjectId(paymentIntent.customer); + const paymentMethodId = this.stripeObjectId(paymentIntent.payment_method); + if (!request.stripeCustomerId || customerId !== request.stripeCustomerId) { + throw new ConflictException('Stripe PaymentIntent customer identity does not match'); + } + if (!request.stripePaymentMethodId + || paymentMethodId !== request.stripePaymentMethodId + || payment.gatewayPaymentToken !== request.stripePaymentMethodId + || payment.method !== 'credit_card') { + throw new ConflictException('Stripe PaymentIntent payment method identity does not match'); + } + } + + private stripeObjectId(value: string | { id: string } | null): string | null { + return typeof value === 'string' ? value : value?.id ?? null; + } + + private fromStripeMinorUnits(amount: number, currencyCode: string): Decimal { + const normalized = currencyCode.trim().toUpperCase(); + let exponent: number | undefined; + try { + exponent = new Intl.NumberFormat('en', { + style: 'currency', + currency: normalized, + }).resolvedOptions().maximumFractionDigits; + } catch { + throw new BadRequestException(`Unsupported Stripe currency '${currencyCode}'`); + } + if (exponent == null) { + throw new BadRequestException(`Unable to resolve Stripe currency '${currencyCode}'`); + } + if (exponent > 2) { + throw new BadRequestException( + `${normalized} minor-unit exponent ${exponent} exceeds ledger storage precision`, + ); + } + const result = new Decimal(amount).div(new Decimal(10).pow(exponent)); + if (result.decimalPlaces() > 2) { + throw new BadRequestException( + `Stripe refund amount for ${normalized} exceeds ledger storage precision`, + ); + } + return result; + } + + private async findPaymentByGatewayTransactionId(transactionId: string) { + const candidates = await this.db + .select() + .from(payments) + .where(and( + eq(payments.gatewayTransactionId, transactionId), + eq(payments.gatewayProvider, 'stripe'), + )) + .limit(2); + if (candidates.length > 1) { + throw new ConflictException( + `Stripe PaymentIntent ${transactionId} is ambiguously linked to multiple payments`, + ); + } + return candidates[0] ?? null; + } + + private async findPaymentByCorrelation(correlation: PaymentIntentCorrelation) { + const candidates = await this.db + .select() + .from(payments) + .where(and( + eq(payments.id, correlation.paymentId), + eq(payments.propertyId, correlation.propertyId), + eq(payments.bookingRequestId, correlation.bookingRequestId), + eq(payments.gatewayProvider, 'stripe'), + )) + .limit(2); + if (candidates.length > 1) { + throw new ConflictException('Stripe PaymentIntent metadata is ambiguously linked'); + } + return candidates[0] ?? null; + } +} diff --git a/apps/api/src/modules/booking-request/booking-request-submission.spec.ts b/apps/api/src/modules/booking-request/booking-request-submission.spec.ts new file mode 100644 index 00000000..a709ecd1 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-submission.spec.ts @@ -0,0 +1,1063 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, +} from '@nestjs/common'; +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { + auditLogs, + bookingEngineConfig, + bookingRequestConsequences, + bookingRequests, +} from './booking-request-db.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BookingThrottleGuard } from '../booking-engine/booking-throttle.guard'; +import { BookingRequestPublicController } from './booking-request-public.controller'; +import { BookingRequestService } from './booking-request.service'; +import { CreateRequestCardSetupDto } from './dto/create-request-card-setup.dto'; +import { SubmitBookingRequestDto } from './dto/submit-booking-request.dto'; + +const PROPERTY_ID = 'aaaaaaaa-0000-4000-a000-000000000001'; +const REQUEST_ID = 'bbbbbbbb-0000-4000-a000-000000000001'; +const CONSEQUENCE_ID = 'bbbbbbbb-0000-4000-a000-000000000002'; +const ROOM_TYPE_ID = 'cccccccc-0000-4000-a000-000000000001'; +const RATE_PLAN_ID = 'dddddddd-0000-4000-a000-000000000001'; +const QUESTION_ID = 'eeeeeeee-0000-4000-a000-000000000001'; +const FUTURE_QUESTION_ID = 'eeeeeeee-0000-4000-a000-000000000002'; + +const formQuestion = { + id: QUESTION_ID, + label: 'Purpose of stay', + type: 'single_select' as const, + options: ['Leisure', 'Business'], + order: 0, + isActive: true, + isRequired: true, +}; + +const publicConfig = { + propertyId: PROPERTY_ID, + isEnabled: true, + bookingMode: 'request' as const, + paymentMethodCollection: 'disabled' as const, + paymentMethodClientMode: 'stripe' as const, + stripePublishableKey: 'pk_test_public', + depositPolicy: { type: 'none' as const, refundable: true }, + sellableRoomTypeIds: [ROOM_TYPE_ID], + sellableRatePlanIds: [RATE_PLAN_ID], + formQuestions: [formQuestion], +}; + +const quote = { + propertyId: PROPERTY_ID, + roomTypeId: ROOM_TYPE_ID, + ratePlanId: RATE_PLAN_ID, + checkIn: '2026-10-01', + checkOut: '2026-10-03', + nights: 2, + currencyCode: 'EUR', + lineItems: [ + { date: '2026-10-01', rate: '100.00', tax: '10.00' }, + { date: '2026-10-02', rate: '100.00', tax: '10.00' }, + ], + roomTotal: '200.00', + taxTotal: '20.00', + services: [], + servicesTotal: '0.00', + servicesTaxTotal: '0.00', + grandTotal: '220.00', + depositPolicy: { type: 'none', refundable: true }, + depositDue: '0.00', + cancellationPolicy: { + type: 'flexible', + description: 'Free cancellation before arrival.', + freeCancelHoursBeforeArrival: 24, + }, +}; + +const submitDto = { + idempotencyKey: 'widget-attempt-1', + roomTypeId: ROOM_TYPE_ID, + ratePlanId: RATE_PLAN_ID, + checkIn: '2026-10-01', + checkOut: '2026-10-03', + guestFirstName: 'Ada', + guestLastName: 'Lovelace', + guestEmail: 'ada@example.com', + guestPhone: '+34 600 000 000', + adults: 2, + children: 1, + specialRequests: 'A quiet room, please.', + serviceIds: [], + applicationAnswers: { [QUESTION_ID]: 'Leisure' }, +} as SubmitBookingRequestDto; + +function makeHarness() { + let transactionActive = false; + let insertedValues: Record | undefined; + const storedRequests: Array<{ + id: string; + propertyId: string; + submissionIdempotencyKey: string; + submissionFingerprint: string; + setupIntentId: string | null; + }> = []; + const storedConsequences: Array<{ + id: string; + propertyId: string; + bookingRequestId: string; + kind: string; + payload: Record; + status: 'pending' | 'processing' | 'completed'; + attempts: number; + claimedAt: Date | null; + lastAttemptAt: Date | null; + lastError: string | null; + completedAt: Date | null; + }> = []; + const storedAudits: Array> = []; + const lockedConfig = { + ...structuredClone(publicConfig), + bookingMode: publicConfig.bookingMode as 'instant' | 'request', + paymentMethodCollection: publicConfig.paymentMethodCollection as + | 'disabled' + | 'optional' + | 'required', + }; + let pendingValues: Record | undefined; + const returning = vi.fn(async () => { + const key = String(pendingValues?.['submissionIdempotencyKey'] ?? ''); + if (key && storedRequests.some((row) => row.submissionIdempotencyKey === key)) { + return []; + } + const setupIntentId = pendingValues?.['setupIntentId']; + if ( + setupIntentId + && storedRequests.some((row) => row.setupIntentId === setupIntentId) + ) { + return []; + } + if (key) { + storedRequests.push({ + id: REQUEST_ID, + propertyId: String(pendingValues?.['propertyId'] ?? ''), + submissionIdempotencyKey: key, + submissionFingerprint: String(pendingValues?.['submissionFingerprint'] ?? ''), + setupIntentId: typeof setupIntentId === 'string' ? setupIntentId : null, + }); + } + return [{ id: REQUEST_ID }]; + }); + const values = vi.fn((input: Record) => { + insertedValues = input; + pendingValues = input; + return { + returning, + onConflictDoNothing: vi.fn(() => ({ returning })), + }; + }); + const consequenceValues = vi.fn((input: Record) => { + if (!storedConsequences.some((row) => + row.propertyId === input['propertyId'] + && row.bookingRequestId === input['bookingRequestId'] + && row.kind === input['kind'])) { + storedConsequences.push({ + id: CONSEQUENCE_ID, + propertyId: String(input['propertyId']), + bookingRequestId: String(input['bookingRequestId']), + kind: String(input['kind']), + payload: structuredClone(input['payload'] as Record), + status: 'pending', + attempts: 0, + claimedAt: null, + lastAttemptAt: null, + lastError: null, + completedAt: null, + }); + } + return Promise.resolve(); + }); + const auditValues = vi.fn((input: Record) => { + storedAudits.push(structuredClone(input)); + return Promise.resolve(); + }); + const db: Record = { + insert: vi.fn((table: unknown) => { + if (table === bookingRequests) return { values }; + if (table === bookingRequestConsequences) return { values: consequenceValues }; + if (table === auditLogs) return { values: auditValues }; + throw new Error('Submission attempted a forbidden database write'); + }), + }; + db['select'] = vi.fn(() => { + let table: unknown; + const chain: Record & PromiseLike = { + from: vi.fn((selectedTable: unknown) => { + table = selectedTable; + return chain; + }), + where: vi.fn(() => chain), + for: vi.fn(async () => { + if (table === bookingEngineConfig) return [lockedConfig]; + if (table === bookingRequestConsequences) return storedConsequences; + return []; + }), + then: (resolve, reject) => Promise.resolve( + table === bookingRequests + ? storedRequests + : table === bookingRequestConsequences + ? storedConsequences + : [], + ).then(resolve, reject), + }; + return chain; + }); + db['update'] = vi.fn((table: unknown) => { + if (table !== bookingRequestConsequences) { + throw new Error('Submission attempted a forbidden database update'); + } + return { + set: vi.fn((changes: Record) => ({ + where: vi.fn(() => { + const row = storedConsequences[0]; + if (row) Object.assign(row, changes); + const result = row ? [structuredClone(row)] : []; + return { + returning: vi.fn(async () => result), + then: ( + resolve: (value: unknown) => unknown, + reject: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + }), + })), + }; + }); + let transactionQueue = Promise.resolve(); + db['transaction'] = vi.fn(async (callback: (tx: unknown) => Promise) => { + const previous = transactionQueue; + let release = () => undefined; + transactionQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + transactionActive = true; + try { + return await callback(db); + } finally { + transactionActive = false; + release(); + } + }); + const config = { + getPublicConfig: vi.fn().mockResolvedValue(structuredClone(publicConfig)), + }; + const availability = { + searchAvailability: vi.fn().mockResolvedValue([ + { roomTypeId: ROOM_TYPE_ID, date: '2026-10-01', available: 1 }, + { roomTypeId: ROOM_TYPE_ID, date: '2026-10-02', available: 1 }, + ]), + }; + const ratePlan = { + assertSellable: vi.fn().mockResolvedValue(undefined), + }; + const bookingEngine = { + quote: vi.fn().mockResolvedValue(structuredClone(quote)), + }; + const savedPaymentMethod = { + createSetup: vi.fn().mockResolvedValue({ + setupIntentId: 'seti_trusted', + clientSecret: 'seti_trusted_secret_value', + customerId: 'cus_trusted', + clientMode: 'stripe' as const, + }), + resolveSetup: vi.fn().mockResolvedValue({ + setupIntentId: 'seti_trusted', + customerId: 'cus_trusted', + paymentMethodId: 'pm_trusted', + cardLastFour: '4242', + cardBrand: 'visa', + }), + }; + const webhook = { + emit: vi.fn().mockResolvedValue(undefined), + dispatchPersisted: vi.fn().mockResolvedValue(undefined), + }; + const mailer = { + queue: vi.fn(async () => { + expect(transactionActive).toBe(true); + return 'email-delivery-1'; + }), + deliverForRequestBestEffort: vi.fn(async () => { + expect(transactionActive).toBe(false); + }), + }; + const service = new BookingRequestService( + db as unknown as ConstructorParameters[0], + config as unknown as ConstructorParameters[1], + bookingEngine as unknown as ConstructorParameters[2], + availability as unknown as ConstructorParameters[3], + ratePlan as unknown as ConstructorParameters[4], + savedPaymentMethod as unknown as ConstructorParameters[5], + webhook as unknown as ConstructorParameters[6], + undefined as unknown as ConstructorParameters[7], + undefined as unknown as ConstructorParameters[8], + undefined as unknown as ConstructorParameters[9], + undefined as unknown as ConstructorParameters[10], + mailer as unknown as ConstructorParameters[11], + ); + + return { + service, + db, + config, + availability, + ratePlan, + bookingEngine, + savedPaymentMethod, + webhook, + mailer, + values, + consequenceValues, + auditValues, + lockedConfig, + storedRequests, + storedConsequences, + storedAudits, + get insertedValues() { + return insertedValues; + }, + }; +} + +describe('BookingRequestPublicController validation contract', () => { + it('retains concrete DTO metadata for the global Nest validation pipe', () => { + expect(Reflect.getMetadata( + 'design:paramtypes', + BookingRequestPublicController.prototype, + 'createSetup', + )?.[0]).toBe(CreateRequestCardSetupDto); + expect(Reflect.getMetadata( + 'design:paramtypes', + BookingRequestPublicController.prototype, + 'submit', + )?.[0]).toBe(SubmitBookingRequestDto); + }); + + it('throttles both public write endpoints', () => { + const setupGuards = Reflect.getMetadata( + GUARDS_METADATA, + BookingRequestPublicController.prototype.createSetup, + ) as unknown[]; + const submitGuards = Reflect.getMetadata( + GUARDS_METADATA, + BookingRequestPublicController.prototype.submit, + ) as unknown[]; + + expect(setupGuards).toContain(BookingThrottleGuard); + expect(submitGuards).toContain(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; + } + }); +}); + +describe('SubmitBookingRequestDto calendar dates and replay key', () => { + async function errors(overrides: Record) { + return validate(plainToInstance(SubmitBookingRequestDto, { + ...submitDto, + ...overrides, + })); + } + + it.each([ + ['2026-10-01T12:00:00Z', '2026-10-03'], + ['2026-02-30', '2026-03-03'], + ['2026-10-03', '2026-10-03'], + ['2026-10-04', '2026-10-03'], + ])('rejects a non-canonical stay from %s to %s', async (checkIn, checkOut) => { + const result = await errors({ checkIn, checkOut }); + + expect(result.some((error) => ['checkIn', 'checkOut'].includes(error.property))).toBe(true); + }); + + it('requires a durable client submission idempotency key', async () => { + const result = await errors({ idempotencyKey: undefined }); + + expect(result.some((error) => error.property === 'idempotencyKey')).toBe(true); + }); + + it('rejects duplicate ancillary service IDs at the public request boundary', async () => { + const serviceId = 'ffffffff-0000-4000-a000-000000000001'; + const result = await errors({ serviceIds: [serviceId, serviceId] }); + + expect(result).toEqual(expect.arrayContaining([ + expect.objectContaining({ property: 'serviceIds' }), + ])); + }); +}); + +describe('BookingRequestService public card setup', () => { + it.each([ + ['the booking engine is disabled', { isEnabled: false }, ForbiddenException], + ['the property uses instant mode', { bookingMode: 'instant' }, ForbiddenException], + [ + 'card collection is disabled', + { paymentMethodCollection: 'disabled' }, + BadRequestException, + ], + ['no public card key is configured', { stripePublishableKey: null }, BadRequestException], + ])('rejects setup when %s', async (_reason, configOverride, errorType) => { + const harness = makeHarness(); + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + paymentMethodCollection: 'optional', + ...configOverride, + }); + + await expect(harness.service.createPaymentMethodSetup(PROPERTY_ID, { + guestEmail: 'ada@example.com', + applicationId: 'widget-application-1', + idempotencyKey: 'widget-attempt-1', + })).rejects.toBeInstanceOf(errorType); + expect(harness.savedPaymentMethod.createSetup).not.toHaveBeenCalled(); + }); + + it('rejects required card setup when the configured provider is unsupported', async () => { + const harness = makeHarness(); + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + paymentMethodCollection: 'required', + paymentMethodClientMode: 'unsupported', + }); + + await expect(harness.service.createPaymentMethodSetup(PROPERTY_ID, { + guestEmail: 'ada@example.com', + applicationId: 'widget-application-1', + idempotencyKey: 'widget-attempt-1', + })).rejects.toThrow(/unavailable/i); + expect(harness.savedPaymentMethod.createSetup).not.toHaveBeenCalled(); + }); + + it('creates an idempotent setup only for request-mode card collection', async () => { + const harness = makeHarness(); + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + paymentMethodCollection: 'optional', + }); + + await expect(harness.service.createPaymentMethodSetup(PROPERTY_ID, { + guestEmail: 'ada@example.com', + applicationId: 'widget-application-1', + idempotencyKey: 'widget-attempt-1', + })).resolves.toEqual({ + setupIntentId: 'seti_trusted', + clientSecret: 'seti_trusted_secret_value', + clientMode: 'stripe', + }); + expect(harness.savedPaymentMethod.createSetup).toHaveBeenCalledWith( + 'ada@example.com', + `booking-request:${PROPERTY_ID}:widget-attempt-1`, + { propertyId: PROPERTY_ID, applicationId: 'widget-application-1' }, + ); + }); + + it('creates a mock setup without requiring Stripe keys', async () => { + const harness = makeHarness(); + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + paymentMethodCollection: 'required', + paymentMethodClientMode: 'mock', + stripePublishableKey: null, + }); + harness.savedPaymentMethod.createSetup.mockResolvedValueOnce({ + setupIntentId: 'seti_mock_local', + clientSecret: 'seti_mock_local_secret_mock', + customerId: 'cus_mock_local', + clientMode: 'mock', + }); + + await expect(harness.service.createPaymentMethodSetup(PROPERTY_ID, { + guestEmail: 'ada@example.com', + applicationId: 'widget-application-1', + idempotencyKey: 'widget-attempt-1', + })).resolves.toEqual({ + setupIntentId: 'seti_mock_local', + clientSecret: 'seti_mock_local_secret_mock', + clientMode: 'mock', + }); + }); +}); + +describe('BookingRequestService.submit', () => { + let harness: ReturnType; + + beforeEach(() => { + harness = makeHarness(); + }); + + it('rejects request submission in instant mode before validation or writes', async () => { + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + bookingMode: 'instant', + }); + + await expect(harness.service.submit(PROPERTY_ID, submitDto)).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(harness.ratePlan.assertSellable).not.toHaveBeenCalled(); + expect(harness.db.insert).not.toHaveBeenCalled(); + }); + + it('validates answers against the current active form before writes', async () => { + await expect(harness.service.submit(PROPERTY_ID, { + ...submitDto, + applicationAnswers: {}, + })).rejects.toThrow(/Purpose of stay/); + expect(harness.ratePlan.assertSellable).not.toHaveBeenCalled(); + expect(harness.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects a stale or invalid rate plan before availability and quote work', async () => { + harness.ratePlan.assertSellable.mockRejectedValue( + new BadRequestException('Rate plan is inactive'), + ); + + await expect(harness.service.submit(PROPERTY_ID, submitDto)).rejects.toThrow( + 'Rate plan is inactive', + ); + expect(harness.ratePlan.assertSellable).toHaveBeenCalledWith( + PROPERTY_ID, + RATE_PLAN_ID, + '2026-10-01', + '2026-10-03', + ); + expect(harness.availability.searchAvailability).not.toHaveBeenCalled(); + expect(harness.db.insert).not.toHaveBeenCalled(); + }); + + it('keeps zero availability as waitlist behavior and creates no request', async () => { + harness.availability.searchAvailability.mockResolvedValue([ + { roomTypeId: ROOM_TYPE_ID, date: '2026-10-01', available: 1 }, + { roomTypeId: ROOM_TYPE_ID, date: '2026-10-02', available: 0 }, + ]); + + await expect(harness.service.submit(PROPERTY_ID, submitDto)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(harness.bookingEngine.quote).not.toHaveBeenCalled(); + expect(harness.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects a partial availability response for the requested stay', async () => { + harness.availability.searchAvailability.mockResolvedValue([ + { roomTypeId: ROOM_TYPE_ID, date: '2026-10-01', available: 1 }, + ]); + + await expect(harness.service.submit(PROPERTY_ID, submitDto)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(harness.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects date-times before they can bypass complete-stay availability checks', async () => { + await expect(harness.service.submit(PROPERTY_ID, { + ...submitDto, + checkIn: '2026-10-01T12:00:00Z', + })).rejects.toBeInstanceOf(BadRequestException); + expect(harness.ratePlan.assertSellable).not.toHaveBeenCalled(); + expect(harness.availability.searchAvailability).not.toHaveBeenCalled(); + expect(harness.db.insert).not.toHaveBeenCalled(); + }); + + it('requires a successful setup and explicit consent under the required policy', async () => { + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + paymentMethodCollection: 'required', + }); + + await expect(harness.service.submit(PROPERTY_ID, submitDto)).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect(harness.service.submit(PROPERTY_ID, { + ...submitDto, + setupIntentId: 'seti_trusted', + })).rejects.toBeInstanceOf(BadRequestException); + expect(harness.savedPaymentMethod.resolveSetup).not.toHaveBeenCalled(); + expect(harness.db.insert).not.toHaveBeenCalled(); + }); + + it('rejects required card submission before quote or writes when collection is unavailable', async () => { + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + paymentMethodCollection: 'required', + paymentMethodClientMode: 'unsupported', + }); + harness.lockedConfig.paymentMethodCollection = 'required'; + + await expect(harness.service.submit(PROPERTY_ID, { + ...submitDto, + setupIntentId: 'seti_trusted', + consentAccepted: true, + consentText: 'Save this card for later staff-initiated payments; no charge is made now.', + consentVersion: 'request-card-v1', + })).rejects.toThrow(/unavailable/i); + expect(harness.ratePlan.assertSellable).not.toHaveBeenCalled(); + expect(harness.bookingEngine.quote).not.toHaveBeenCalled(); + expect(harness.db.insert).not.toHaveBeenCalled(); + }); + + it('allows optional card collection to be skipped explicitly by omitting setup data', async () => { + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + paymentMethodCollection: 'optional', + }); + harness.lockedConfig.paymentMethodCollection = 'optional'; + + await expect(harness.service.submit(PROPERTY_ID, submitDto)).resolves.toEqual({ + requestId: REQUEST_ID, + status: 'pending', + message: 'Your booking request has been received and is pending review.', + }); + expect(harness.savedPaymentMethod.resolveSetup).not.toHaveBeenCalled(); + expect(harness.insertedValues).toMatchObject({ + stripeCustomerId: null, + stripePaymentMethodId: null, + cardLastFour: null, + cardBrand: null, + consentText: null, + consentVersion: null, + consentedAt: null, + }); + }); + + it.each([ + ['BHD', '220.000', /BHD.*scale-two payment ledger/i], + ['IQD', '220.000', /IQD.*scale-two payment ledger/i], + ['ZZZ', '220.00', /unsupported ISO-4217 currency/i], + ])('rejects a %s authoritative quote before resolving a card or creating a request obligation', async ( + currencyCode, + grandTotal, + expectedError, + ) => { + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + paymentMethodCollection: 'required', + }); + harness.lockedConfig.paymentMethodCollection = 'required'; + harness.bookingEngine.quote.mockResolvedValue({ + ...structuredClone(quote), + currencyCode, + grandTotal, + }); + + await expect(harness.service.submit(PROPERTY_ID, { + ...submitDto, + setupIntentId: 'seti_client_reference_only', + consentAccepted: true, + consentText: 'Save this card for later staff-initiated payments; no charge is made now.', + consentVersion: 'request-card-v1', + })).rejects.toThrow(expectedError); + + expect(harness.savedPaymentMethod.resolveSetup).not.toHaveBeenCalled(); + expect(harness.storedRequests).toHaveLength(0); + expect(harness.db.transaction).not.toHaveBeenCalled(); + }); + + it.each(['required', 'optional'] as const)( + 'resolves trusted saved-card details and stores consent for %s collection', + async (paymentMethodCollection) => { + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + paymentMethodCollection, + }); + harness.lockedConfig.paymentMethodCollection = paymentMethodCollection; + const dto = { + ...submitDto, + setupIntentId: 'seti_client_reference_only', + consentAccepted: true, + consentText: 'Save this card for later staff-initiated payments; no charge is made now.', + consentVersion: 'request-card-v1', + } satisfies SubmitBookingRequestDto; + + await harness.service.submit(PROPERTY_ID, dto); + + expect(harness.savedPaymentMethod.resolveSetup).toHaveBeenCalledWith( + 'seti_client_reference_only', + { propertyId: PROPERTY_ID, applicationId: 'widget-attempt-1' }, + ); + expect(harness.insertedValues).toMatchObject({ + setupIntentId: 'seti_trusted', + stripeCustomerId: 'cus_trusted', + stripePaymentMethodId: 'pm_trusted', + cardLastFour: '4242', + cardBrand: 'visa', + consentText: dto.consentText, + consentVersion: 'request-card-v1', + }); + expect(harness.insertedValues?.['consentedAt']).toBeInstanceOf(Date); + }, + ); + + it('rejects optional setup data without matching consent and disabled setup data entirely', async () => { + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + paymentMethodCollection: 'optional', + }); + await expect(harness.service.submit(PROPERTY_ID, { + ...submitDto, + setupIntentId: 'seti_trusted', + })).rejects.toBeInstanceOf(BadRequestException); + + harness.config.getPublicConfig.mockResolvedValue(structuredClone(publicConfig)); + await expect(harness.service.submit(PROPERTY_ID, { + ...submitDto, + setupIntentId: 'seti_trusted', + consentAccepted: true, + consentText: 'Unexpected consent', + consentVersion: 'unexpected-v1', + })).rejects.toBeInstanceOf(BadRequestException); + expect(harness.savedPaymentMethod.resolveSetup).not.toHaveBeenCalled(); + expect(harness.db.insert).not.toHaveBeenCalled(); + }); + + it('turns an untrusted or incomplete SetupIntent into a validation failure', async () => { + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + paymentMethodCollection: 'required', + }); + harness.savedPaymentMethod.resolveSetup.mockRejectedValue( + new Error('Stripe SetupIntent has not succeeded'), + ); + + await expect(harness.service.submit(PROPERTY_ID, { + ...submitDto, + setupIntentId: 'seti_untrusted', + consentAccepted: true, + consentText: 'Save the card without charging it now.', + consentVersion: 'request-card-v1', + })).rejects.toBeInstanceOf(BadRequestException); + expect(harness.db.insert).not.toHaveBeenCalled(); + }); + + it('persists immutable authoritative snapshots and returns acknowledgement only', async () => { + const currentConfig = structuredClone(publicConfig); + const currentQuote = structuredClone(quote); + harness.config.getPublicConfig.mockResolvedValue(currentConfig); + harness.bookingEngine.quote.mockResolvedValue(currentQuote); + const dto = structuredClone(submitDto); + + const acknowledgement = await harness.service.submit(PROPERTY_ID, dto); + + expect(harness.bookingEngine.quote).toHaveBeenCalledWith(PROPERTY_ID, { + roomTypeId: ROOM_TYPE_ID, + ratePlanId: RATE_PLAN_ID, + checkIn: '2026-10-01', + checkOut: '2026-10-03', + adults: 2, + children: 1, + serviceIds: [], + }); + expect(harness.db.insert).toHaveBeenCalledTimes(3); + expect(harness.values).toHaveBeenCalledOnce(); + expect(harness.insertedValues).toMatchObject({ + propertyId: PROPERTY_ID, + status: 'pending', + arrivalDate: '2026-10-01', + departureDate: '2026-10-03', + roomTypeId: ROOM_TYPE_ID, + ratePlanId: RATE_PLAN_ID, + adults: 2, + children: 1, + guestFirstName: 'Ada', + guestLastName: 'Lovelace', + guestEmail: 'ada@example.com', + guestPhone: '+34 600 000 000', + specialRequests: 'A quiet room, please.', + serviceIds: [], + formSnapshot: [formQuestion], + applicationAnswers: { [QUESTION_ID]: 'Leisure' }, + submittedQuoteSnapshot: quote, + submittedTotal: '220.00', + currentQuoteSnapshot: null, + currencyCode: 'EUR', + }); + expect(harness.insertedValues?.['formSnapshot']).not.toBe(currentConfig.formQuestions); + expect(harness.insertedValues?.['submittedQuoteSnapshot']).not.toBe(currentQuote); + + currentConfig.formQuestions[0]!.label = 'Changed after submission'; + currentQuote.grandTotal = '9999.00'; + dto.applicationAnswers[QUESTION_ID] = 'Business'; + expect(harness.insertedValues?.['formSnapshot']).toEqual([formQuestion]); + expect(harness.insertedValues?.['submittedQuoteSnapshot']).toEqual(quote); + expect(harness.insertedValues?.['applicationAnswers']).toEqual({ + [QUESTION_ID]: 'Leisure', + }); + expect(acknowledgement).toEqual({ + requestId: REQUEST_ID, + status: 'pending', + message: 'Your booking request has been received and is pending review.', + }); + expect(Object.keys(acknowledgement).sort()).toEqual(['message', 'requestId', 'status']); + }); + + it('commits a durable audit/outbox and dispatches its sanitized created event', async () => { + await harness.service.submit(PROPERTY_ID, submitDto); + + expect(harness.storedConsequences).toHaveLength(1); + expect(harness.storedConsequences[0]).toMatchObject({ + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + kind: 'created_event', + status: 'completed', + attempts: 1, + lastError: null, + }); + expect(harness.storedAudits).toEqual([expect.objectContaining({ + propertyId: PROPERTY_ID, + action: 'create', + entityType: 'booking_request', + entityId: REQUEST_ID, + description: 'Webhook event: booking_request.created', + })]); + const payload = expect.objectContaining({ + event: 'booking_request.created', + entityType: 'booking_request', + entityId: REQUEST_ID, + propertyId: PROPERTY_ID, + data: { requestId: REQUEST_ID, status: 'pending' }, + timestamp: expect.any(String), + }); + expect(harness.storedConsequences[0]?.payload).toEqual(payload); + expect(harness.storedAudits[0]?.['newValue']).toEqual(payload); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledWith( + payload, + CONSEQUENCE_ID, + ); + expect(harness.values.mock.invocationCallOrder[0]).toBeLessThan( + harness.webhook.dispatchPersisted.mock.invocationCallOrder[0]!, + ); + expect(JSON.stringify(harness.webhook.dispatchPersisted.mock.calls)).not.toContain('Leisure'); + expect(JSON.stringify(harness.webhook.dispatchPersisted.mock.calls)).not.toContain('consent'); + expect(JSON.stringify(harness.webhook.dispatchPersisted.mock.calls)).not.toContain('seti_'); + expect(harness.mailer.queue).toHaveBeenCalledWith(expect.objectContaining({ + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + logicalKey: 'request:receipt', + kind: 'receipt', + recipient: 'ada@example.com', + }), harness.db); + expect(JSON.stringify(harness.mailer.queue.mock.calls)).not.toMatch( + /Leisure|consent|seti_|pm_|cus_|widget-attempt|https?:\/\//i, + ); + expect(harness.mailer.deliverForRequestBestEffort).toHaveBeenCalledWith( + REQUEST_ID, + PROPERTY_ID, + ); + }); + + it('does not roll back a submitted request when post-commit email delivery fails', async () => { + harness.mailer.deliverForRequestBestEffort.mockRejectedValueOnce( + new Error('transport unavailable'), + ); + + await expect(harness.service.submit(PROPERTY_ID, submitDto)).resolves.toMatchObject({ + requestId: REQUEST_ID, + status: 'pending', + }); + expect(harness.storedRequests).toHaveLength(1); + expect(harness.storedConsequences).toHaveLength(1); + }); + + it('returns the existing acknowledgement for an exact replay without repeating work', async () => { + const first = await harness.service.submit(PROPERTY_ID, submitDto); + const replay = await harness.service.submit(PROPERTY_ID, structuredClone(submitDto)); + + expect(replay).toEqual(first); + expect(harness.values).toHaveBeenCalledOnce(); + expect(harness.bookingEngine.quote).toHaveBeenCalledOnce(); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledOnce(); + }); + + it('conflicts when a replay key is reused for a different submission payload', async () => { + await harness.service.submit(PROPERTY_ID, submitDto); + + await expect(harness.service.submit(PROPERTY_ID, { + ...submitDto, + guestLastName: 'Byron', + })).rejects.toBeInstanceOf(ConflictException); + expect(harness.values).toHaveBeenCalledOnce(); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledOnce(); + }); + + it('rejects reuse of one trusted SetupIntent under another application key', async () => { + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + paymentMethodCollection: 'required', + }); + harness.lockedConfig.paymentMethodCollection = 'required'; + const withCard = { + ...submitDto, + setupIntentId: 'seti_client_reference_only', + consentAccepted: true, + consentText: 'Save this card for staff-initiated payments; no charge is made now.', + consentVersion: 'request-card-v1', + } satisfies SubmitBookingRequestDto; + await harness.service.submit(PROPERTY_ID, withCard); + + await expect(harness.service.submit(PROPERTY_ID, { + ...withCard, + idempotencyKey: 'widget-attempt-2', + })).rejects.toBeInstanceOf(ConflictException); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledOnce(); + }); + + it('collapses concurrent exact replays into one request and one created event', async () => { + const [first, replay] = await Promise.all([ + harness.service.submit(PROPERTY_ID, structuredClone(submitDto)), + harness.service.submit(PROPERTY_ID, structuredClone(submitDto)), + ]); + + expect(replay).toEqual(first); + expect(harness.values).toHaveBeenCalledOnce(); + expect(harness.storedConsequences).toHaveLength(1); + expect(harness.storedAudits).toHaveLength(1); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledOnce(); + }); + + it('persists a failed consequence, retries it on replay, and never redelivers completion', async () => { + harness.webhook.dispatchPersisted.mockRejectedValueOnce(new Error('delivery unavailable')); + + await expect(harness.service.submit(PROPERTY_ID, submitDto)).resolves.toEqual({ + requestId: REQUEST_ID, + status: 'pending', + message: 'Your booking request has been received and is pending review.', + }); + expect(harness.storedConsequences[0]).toMatchObject({ + status: 'pending', + attempts: 1, + lastError: 'delivery unavailable', + completedAt: null, + }); + expect(harness.storedAudits).toHaveLength(1); + + await expect(harness.service.submit(PROPERTY_ID, structuredClone(submitDto))).resolves.toEqual({ + requestId: REQUEST_ID, + status: 'pending', + message: 'Your booking request has been received and is pending review.', + }); + expect(harness.storedConsequences[0]).toMatchObject({ + status: 'completed', + attempts: 2, + lastError: null, + }); + + await expect(harness.service.submit(PROPERTY_ID, structuredClone(submitDto))).resolves.toEqual({ + requestId: REQUEST_ID, + status: 'pending', + message: 'Your booking request has been received and is pending review.', + }); + expect(harness.values).toHaveBeenCalledOnce(); + expect(harness.consequenceValues).toHaveBeenCalledOnce(); + expect(harness.auditValues).toHaveBeenCalledOnce(); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledTimes(2); + }); + + it('allows only one concurrent replay to claim a pending consequence', async () => { + harness.webhook.dispatchPersisted.mockRejectedValueOnce(new Error('delivery unavailable')); + await harness.service.submit(PROPERTY_ID, submitDto); + + await Promise.all([ + harness.service.submit(PROPERTY_ID, structuredClone(submitDto)), + harness.service.submit(PROPERTY_ID, structuredClone(submitDto)), + ]); + + expect(harness.values).toHaveBeenCalledOnce(); + expect(harness.storedConsequences).toHaveLength(1); + expect(harness.storedConsequences[0]).toMatchObject({ + status: 'completed', + attempts: 2, + lastError: null, + }); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledTimes(2); + }); + + it('does not commit when the locked final config has switched to instant mode', async () => { + harness.lockedConfig.bookingMode = 'instant'; + + await expect(harness.service.submit(PROPERTY_ID, submitDto)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(harness.values).not.toHaveBeenCalled(); + expect(harness.webhook.dispatchPersisted).not.toHaveBeenCalled(); + }); + + it('ignores a stored active future question in both public and locked config semantics', async () => { + (harness.lockedConfig.formQuestions as Array>).push({ + id: FUTURE_QUESTION_ID, + label: 'Future satisfaction score', + type: 'rating_scale', + order: 1, + isActive: true, + isRequired: false, + futureConfig: { maximum: 5, icon: 'star' }, + }); + + await expect(harness.service.submit(PROPERTY_ID, submitDto)).resolves.toMatchObject({ + requestId: REQUEST_ID, + status: 'pending', + }); + expect(harness.insertedValues?.['formSnapshot']).toEqual([formQuestion]); + }); + + it('still detects a supported question change during the locked config recheck', async () => { + harness.lockedConfig.formQuestions[0] = { + ...harness.lockedConfig.formQuestions[0]!, + label: 'Updated purpose of stay', + }; + + await expect(harness.service.submit(PROPERTY_ID, submitDto)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(harness.values).not.toHaveBeenCalled(); + }); + + it('does not commit a card-policy snapshot that changed during submission', async () => { + harness.lockedConfig.paymentMethodCollection = 'required'; + + await expect(harness.service.submit(PROPERTY_ID, submitDto)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(harness.values).not.toHaveBeenCalled(); + expect(harness.webhook.dispatchPersisted).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/booking-request/booking-request.controller.ts b/apps/api/src/modules/booking-request/booking-request.controller.ts new file mode 100644 index 00000000..0376ec8e --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request.controller.ts @@ -0,0 +1,337 @@ +import { + Body, + Controller, + Delete, + Get, + Inject, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { + AuditActorCtx, + type AuditActor, +} from '../../common/audit/audit-actor'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { BookingRequestPaymentService } from './booking-request-payment.service'; +import { BookingRequestMailerService } from './booking-request-mailer.service'; +import { BookingRequestService } from './booking-request.service'; +// DTOs must remain runtime imports for Nest validation metadata. +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { AcceptBookingRequestDto } from './dto/accept-booking-request.dto'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { + AmendBookingRequestStayDto, + PreviewBookingRequestStayAmendmentDto, +} from './dto/amend-booking-request-stay.dto'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { DenyBookingRequestDto } from './dto/deny-booking-request.dto'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ListBookingRequestsDto } from './dto/list-booking-requests.dto'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ListBookingRequestAuditDto } from './dto/list-booking-request-audit.dto'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { + AllocateBookingRequestPaymentDto, + ChargeBookingRequestCardDto, + CreateBookingRequestInstallmentDto, + RecordBookingRequestExternalPaymentDto, + RecordBookingRequestExternalReturnDto, + ReorderBookingRequestInstallmentsDto, + RefundBookingRequestPaymentDto, + RetainBookingRequestPaymentDto, + UpdateBookingRequestInstallmentDto, +} from './dto/booking-request-payment.dto'; + +@ApiTags('booking-requests') +@Controller('booking-requests') +export class BookingRequestController { + constructor( + @Inject(BookingRequestService) private readonly service: BookingRequestService, + @Inject(BookingRequestPaymentService) + private readonly paymentService: BookingRequestPaymentService, + @Inject(BookingRequestMailerService) + private readonly mailer: BookingRequestMailerService, + ) {} + + @Get() + @RequirePermissions('reservations.read') + @ApiOperation({ summary: 'List property-scoped booking requests' }) + list(@Query() dto: ListBookingRequestsDto) { + return this.service.list(dto); + } + + @Get(':id') + @RequirePermissions('reservations.read') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Get a property-scoped booking request' }) + findById( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + ) { + return this.service.findById(id, propertyId); + } + + @Get(':id/audit-history') + @RequirePermissions('reservations.read') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'List immutable sanitized Booking Request audit history' }) + auditHistory( + @Param('id', ParseUUIDPipe) id: string, + @Query() query: ListBookingRequestAuditDto, + ) { + return this.service.auditHistory(id, query.propertyId, query); + } + + @Get(':id/acceptance-preview') + @RequirePermissions('reservations.read') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Preview authoritative totals before accepting a request' }) + acceptancePreview( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + ) { + return this.service.acceptancePreview(id, propertyId); + } + + @Get(':id/stay-amendment-preview') + @RequirePermissions('reservations.read') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Preview an accepted Booking Request stay amendment' }) + stayAmendmentPreview( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Query() dto: PreviewBookingRequestStayAmendmentDto, + ) { + return this.service.stayAmendmentPreview(id, propertyId, dto); + } + + @Post(':id/stay-amendments') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Atomically amend an accepted Booking Request stay' }) + amendStay( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Body() dto: AmendBookingRequestStayDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.service.amendStay(id, propertyId, dto, actor); + } + + @Get(':id/emails') + @RequirePermissions('reservations.read') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'List Booking Request email delivery history' }) + listEmailDeliveries( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + ) { + return this.mailer.listForRequest(id, propertyId); + } + + @Post(':id/emails/:deliveryId/retry') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Retry a Booking Request email delivery' }) + retryEmailDelivery( + @Param('id', ParseUUIDPipe) id: string, + @Param('deliveryId', ParseUUIDPipe) deliveryId: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @AuditActorCtx() actor: AuditActor, + ) { + return this.mailer.retry(deliveryId, id, propertyId, actor); + } + + @Post(':id/accept') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Accept a request and create its reservation' }) + accept( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Body() dto: AcceptBookingRequestDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.service.accept(id, propertyId, dto, actor); + } + + @Post(':id/deny') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Deny a request after resolving captured money' }) + deny( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Body() dto: DenyBookingRequestDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.service.deny(id, propertyId, dto, actor); + } + + @Get(':id/installments') + @RequirePermissions('reservations.read') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'List a Booking Request payment plan' }) + listInstallments( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + ) { + return this.paymentService.listInstallments(id, propertyId); + } + + @Post(':id/installments') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Add an informational payment-plan installment' }) + createInstallment( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Body() dto: CreateBookingRequestInstallmentDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.paymentService.createInstallment(id, propertyId, dto, actor); + } + + @Patch(':id/installments/reorder') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Atomically reorder an entire Booking Request payment plan' }) + reorderInstallments( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Body() dto: ReorderBookingRequestInstallmentsDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.paymentService.reorderInstallments(id, propertyId, dto, actor); + } + + @Patch(':id/installments/:installmentId') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Edit an installment without reducing it below durable allocations' }) + updateInstallment( + @Param('id', ParseUUIDPipe) id: string, + @Param('installmentId', ParseUUIDPipe) installmentId: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Body() dto: UpdateBookingRequestInstallmentDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.paymentService.updateInstallment( + id, + installmentId, + propertyId, + dto, + actor, + ); + } + + @Delete(':id/installments/:installmentId') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Delete an unallocated installment or trim a partial installment remainder' }) + deleteInstallment( + @Param('id', ParseUUIDPipe) id: string, + @Param('installmentId', ParseUUIDPipe) installmentId: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @AuditActorCtx() actor: AuditActor, + ) { + return this.paymentService.deleteInstallment(id, installmentId, propertyId, actor); + } + + @Post(':id/installments/:installmentId/allocations') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Allocate a captured movement to an installment' }) + allocatePayment( + @Param('id', ParseUUIDPipe) id: string, + @Param('installmentId', ParseUUIDPipe) installmentId: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Body() dto: AllocateBookingRequestPaymentDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.paymentService.allocatePayment(id, installmentId, propertyId, dto, actor); + } + + @Get(':id/payments') + @RequirePermissions('reservations.read') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'List Booking Request movements and resolutions' }) + listPayments( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + ) { + return this.paymentService.listPayments(id, propertyId); + } + + @Post(':id/payments/charge') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Manually charge the saved Booking Request card' }) + chargeSavedCard( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Body() dto: ChargeBookingRequestCardDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.paymentService.chargeSavedCard(id, propertyId, dto, actor); + } + + @Post(':id/payments/external') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Record externally collected Booking Request money' }) + recordExternalPayment( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Body() dto: RecordBookingRequestExternalPaymentDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.paymentService.recordExternalPayment(id, propertyId, dto, actor); + } + + @Post(':id/payments/:paymentId/refunds') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Partially or fully refund a request gateway payment' }) + refundPayment( + @Param('id', ParseUUIDPipe) id: string, + @Param('paymentId', ParseUUIDPipe) paymentId: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Body() dto: RefundBookingRequestPaymentDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.paymentService.refund(id, paymentId, propertyId, dto, actor); + } + + @Post(':id/payments/:paymentId/external-returns') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Record that externally collected money was returned' }) + recordExternalReturn( + @Param('id', ParseUUIDPipe) id: string, + @Param('paymentId', ParseUUIDPipe) paymentId: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Body() dto: RecordBookingRequestExternalReturnDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.paymentService.recordExternalReturn(id, paymentId, propertyId, dto, actor); + } + + @Post(':id/payments/:paymentId/retentions') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Resolve captured money as retained for denial' }) + retainForDenial( + @Param('id', ParseUUIDPipe) id: string, + @Param('paymentId', ParseUUIDPipe) paymentId: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + @Body() dto: RetainBookingRequestPaymentDto, + @AuditActorCtx() actor: AuditActor, + ) { + return this.paymentService.retainForDenial(id, paymentId, propertyId, dto, actor); + } +} 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..092eb41b --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request.e2e-spec.ts @@ -0,0 +1,979 @@ +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, + auditLogs, + bookingEngineCredentials, + bookingRequestConsequences, + bookingRequestEmailDeliveries, + bookingRequestInstallments, + bookingRequests, + charges, + folios, + payments, + properties, + ratePlans, + reservations, + rooms, + roomTypes, + webhookDeliveries, +} from './booking-request-db.js'; +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('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/push-schema.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 { 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/booking-request.module.ts b/apps/api/src/modules/booking-request/booking-request.module.ts new file mode 100644 index 00000000..288601fa --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request.module.ts @@ -0,0 +1,57 @@ +import { Module } from '@nestjs/common'; +import { AncillaryModule } from '../ancillary/ancillary.module'; +import { BookingEngineModule } from '../booking-engine/booking-engine.module'; +import { BookingThrottleGuard } from '../booking-engine/booking-throttle.guard'; +import { BookingEngineScopeGuard } from '../auth/booking-engine-scope.guard'; +import { BookingKeyGuard } from '../auth/booking-key.guard'; +import { PaymentModule } from '../payment/payment.module'; +import { RatePlanModule } from '../rate-plan/rate-plan.module'; +import { ReservationModule } from '../reservation/reservation.module'; +import { FolioModule } from '../folio/folio.module'; +import { GuestModule } from '../guest/guest.module'; +import { WebhookModule } from '../webhook/webhook.module'; +import { EmailModule } from '../agent/guest-comms/email.module'; +import { BookingRequestController } from './booking-request.controller'; +import { BookingRequestPublicController } from './booking-request-public.controller'; +import { BookingRequestService } from './booking-request.service'; +import { BookingRequestConsequenceWorkerService } from './booking-request-consequence-worker.service'; +import { BookingRequestPaymentService } from './booking-request-payment.service'; +import { BookingRequestMailerService } from './booking-request-mailer.service'; +import { BookingRequestStripeHandler } from './booking-request-stripe.handler'; +import { BOOKING_REQUEST_STRIPE_HANDLER } from '../payment/booking-request-stripe-handler.interface'; + +@Module({ + imports: [ + BookingEngineModule, + ReservationModule, + RatePlanModule, + PaymentModule, + WebhookModule, + GuestModule, + FolioModule, + AncillaryModule, + EmailModule, + ], + controllers: [BookingRequestPublicController, BookingRequestController], + providers: [ + BookingRequestService, + BookingRequestPaymentService, + BookingRequestMailerService, + BookingRequestConsequenceWorkerService, + BookingRequestStripeHandler, + { + provide: BOOKING_REQUEST_STRIPE_HANDLER, + useExisting: BookingRequestStripeHandler, + }, + BookingKeyGuard, + BookingEngineScopeGuard, + BookingThrottleGuard, + ], + exports: [ + BookingRequestService, + BookingRequestPaymentService, + BookingRequestMailerService, + BOOKING_REQUEST_STRIPE_HANDLER, + ], +}) +export class BookingRequestModule {} diff --git a/apps/api/src/modules/booking-request/booking-request.service.ts b/apps/api/src/modules/booking-request/booking-request.service.ts new file mode 100644 index 00000000..ffa1033a --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request.service.ts @@ -0,0 +1,2302 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { + auditLogs, + bookingEngineConfig, + bookingRequestConsequences, + bookingRequestEmailDeliveries, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + bookingRequestPaymentResolutions, + bookingRequestStayAmendments, + bookingRequests, + payments, + properties, + reservationServices, + reservations, +} from './booking-request-db.js'; +import type { + AcceptedPricingSnapshot, + PaymentMethodCollection, +} from './booking-request-db.js'; +import { createHash, randomUUID } from 'node:crypto'; +import { + and, + asc, + count, + desc, + eq, + gte, + ilike, + inArray, + isNotNull, + isNull, + lt, + lte, + or, +} from 'drizzle-orm'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import type { + BookingRequestAcceptedWebhook, + BookingRequestCreatedWebhook, + BookingRequestDeniedWebhook, +} from '@telivityhaip/shared'; +import { + actorFields, + type AuditActor, +} from '../../common/audit/audit-actor'; +import { matchAcceptedReservationServiceRows } from '../../common/accepted-pricing/accepted-reservation-service'; +import { withAcceptedPricingLock } from '../../common/database/accepted-pricing-lock'; +import { DRIZZLE } from '../../database/database.module'; +import { AncillaryService } from '../ancillary/ancillary.service'; +import { reservationServiceAttachedPayload } from '../ancillary/reservation-service-event'; +import { BookingEngineConfigService } from '../booking-engine/booking-engine-config.service'; +import { BookingEngineService } from '../booking-engine/booking-engine.service'; +import { + isSupportedQuestion, + validateApplicationAnswers, + validateQuestionDefinitions, +} from '../booking-engine/booking-form-questions'; +import { + SAVED_PAYMENT_METHOD_GATEWAY, + type SavedPaymentMethod, + type SavedPaymentMethodGateway, +} from '../payment/interfaces/saved-payment-method-gateway.interface'; +import { RatePlanService } from '../rate-plan/rate-plan.service'; +import { FolioService } from '../folio/folio.service'; +import { GuestService } from '../guest/guest.service'; +import { AvailabilityService } from '../reservation/availability.service'; +import { ReservationService } from '../reservation/reservation.service'; +import { + WebhookService, + type WebhookPayload, +} from '../webhook/webhook.service'; +import { assertCanonicalStayDates } from './booking-request-date.validator'; +import { + assertLedgerCurrencySupported, + type BookingRequestPriceSource, +} from './booking-request-money'; +import { summarizeBookingRequestPaymentLedger } from './booking-request-payment-ledger'; +import { assertBookingRequestTransition } from './booking-request-state'; +import { buildAcceptedPricingSnapshot } from './booking-request-pricing'; +import { + buildAmendedPricingSnapshot, + buildPriorAmendedPricingSnapshot, + withoutCancelledAcceptedServices, + type StayAmendmentPriceSource, +} from './booking-request-amendment-pricing'; +import { + acceptedBookingRequestEmail, + deniedBookingRequestEmail, + requestReceivedEmail, +} from './booking-request-email.templates'; +import { BookingRequestMailerService } from './booking-request-mailer.service'; +import type { AcceptBookingRequestDto } from './dto/accept-booking-request.dto'; +import type { CreateRequestCardSetupDto } from './dto/create-request-card-setup.dto'; +import type { DenyBookingRequestDto } from './dto/deny-booking-request.dto'; +import type { ListBookingRequestsDto } from './dto/list-booking-requests.dto'; +import type { SubmitBookingRequestDto } from './dto/submit-booking-request.dto'; +import type { + AmendBookingRequestStayDto, + PreviewBookingRequestStayAmendmentDto, +} from './dto/amend-booking-request-stay.dto'; +import { + toAcceptedBookingRequestDecision, + toBookingRequestDetail, + toBookingRequestListItem, + toDeniedBookingRequestDecision, + toBookingRequestAuditHistoryItem, +} from './dto/booking-request-response.dto'; + +type BookingRequestAuditCursor = { + timelineSequence: string; +}; + +const POSTGRES_BIGINT_MAX = 9_223_372_036_854_775_807n; + +function encodeBookingRequestAuditCursor( + row: Pick, +): string { + const cursor: BookingRequestAuditCursor = { + timelineSequence: row.timelineSequence.toString(), + }; + return Buffer.from(JSON.stringify(cursor)).toString('base64url'); +} + +function decodeBookingRequestAuditCursor(value: string | undefined): BookingRequestAuditCursor | null { + if (!value) return null; + try { + const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Partial; + const timelineSequence = parsed.timelineSequence; + const timelineSequenceValue = typeof timelineSequence === 'string' + && /^[1-9]\d{0,18}$/.test(timelineSequence) + ? BigInt(timelineSequence) + : null; + if ( + Object.keys(parsed).length !== 1 + || typeof timelineSequence !== 'string' + || timelineSequenceValue == null + || timelineSequenceValue > POSTGRES_BIGINT_MAX + ) throw new Error('invalid cursor'); + return { timelineSequence }; + } catch { + throw new BadRequestException('Invalid booking request audit cursor'); + } +} + +function auditRowPrecedesCursor( + row: Pick, + cursor: BookingRequestAuditCursor | null, +): boolean { + if (!cursor) return true; + return row.timelineSequence < BigInt(cursor.timelineSequence); +} + +export type AcceptBookingRequestInput = { + priceSource: BookingRequestPriceSource; + previewToken: string; + customTotal?: string; + customReason?: string; +}; + +type AcceptancePreviewFingerprintInput = { + requestId: string; + propertyId: string; + requestUpdatedAt: Date; + currencyCode: string; + currentTotal: string; +}; + +export function acceptancePreviewFingerprint( + input: AcceptancePreviewFingerprintInput, +): string { + const serialized = JSON.stringify({ + version: 1, + requestId: input.requestId, + propertyId: input.propertyId, + requestUpdatedAt: input.requestUpdatedAt.toISOString(), + currencyCode: input.currencyCode, + currentTotal: input.currentTotal, + }); + return `v1:${createHash('sha256').update(serialized).digest('hex')}`; +} + +type AmendmentPreviewFingerprintInput = { + requestId: string; + propertyId: string; + reservationId: string; + reservationUpdatedAt: Date; + previousArrivalDate: string; + previousDepartureDate: string; + previousTotal: string; + previousPricing: AcceptedPricingSnapshot; + arrivalDate: string; + departureDate: string; + currentQuote: unknown; +}; + +function stableSerialize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) ?? 'undefined'; + } + if (value instanceof Date) return JSON.stringify(value.toISOString()); + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerialize(item)).join(',')}]`; + } + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`) + .join(',')}}`; +} + +export function amendmentPreviewFingerprint( + input: AmendmentPreviewFingerprintInput, +): string { + const serialized = stableSerialize({ version: 1, ...input }); + return `v1:${createHash('sha256').update(serialized).digest('hex')}`; +} + +export type StayAmendmentResult = { + amendmentId: string; + requestId: string; + reservationId: string; + folioId: string; + previousArrivalDate: string; + previousDepartureDate: string; + arrivalDate: string; + departureDate: string; + previousTotalAmount: string; + newTotalAmount: string; + currencyCode: string; + priceSource: StayAmendmentPriceSource; + reason: string | null; +}; + +export type { AuditActor } from '../../common/audit/audit-actor'; + +export type BookingRequestAcknowledgement = { + requestId: string; + status: 'pending'; + message: string; +}; + +type PublicRequestConfig = Awaited< + ReturnType +>; + +type CardSnapshot = { + setupIntentId: string | null; + stripeCustomerId: string | null; + stripePaymentMethodId: string | null; + cardLastFour: string | null; + cardBrand: string | null; + consentText: string | null; + consentVersion: string | null; + consentedAt: Date | null; +}; + +type BookingRequestDatabase = PostgresJsDatabase; + +type ExistingRequest = { + id: string; + propertyId: string; + submissionIdempotencyKey: string; + submissionFingerprint: string; +}; + +type LockedRequestConfig = typeof bookingEngineConfig.$inferSelect; +type CreatedConsequence = typeof bookingRequestConsequences.$inferSelect; +type EmailQueueExecutor = NonNullable[1]>; + +const ACKNOWLEDGEMENT_MESSAGE = + 'Your booking request has been received and is pending review.'; +const CREATED_CONSEQUENCE_KIND = 'created_event' as const; +const ACCEPTED_CONSEQUENCE_KIND = 'accepted_event' as const; +const DENIED_CONSEQUENCE_KIND = 'denied_event' as const; +const RESERVATION_CREATED_CONSEQUENCE_KIND = 'reservation_created_event' as const; +const FOLIO_CREATED_CONSEQUENCE_KIND = 'folio_created_event' as const; +const CONSEQUENCE_CLAIM_LEASE_MS = 5 * 60 * 1000; + +@Injectable() +export class BookingRequestService { + private readonly logger = new Logger(BookingRequestService.name); + + constructor( + @Inject(DRIZZLE) private readonly db: BookingRequestDatabase, + @Inject(BookingEngineConfigService) + private readonly configService: BookingEngineConfigService, + @Inject(BookingEngineService) + private readonly bookingEngineService: BookingEngineService, + @Inject(AvailabilityService) + private readonly availabilityService: AvailabilityService, + @Inject(RatePlanService) + private readonly ratePlanService: RatePlanService, + @Inject(SAVED_PAYMENT_METHOD_GATEWAY) + private readonly savedPaymentMethodGateway: SavedPaymentMethodGateway, + @Inject(WebhookService) private readonly webhookService: WebhookService, + @Inject(GuestService) private readonly guestService: GuestService, + @Inject(ReservationService) + private readonly reservationService: ReservationService, + @Inject(FolioService) private readonly folioService: FolioService, + @Inject(AncillaryService) private readonly ancillaryService: AncillaryService, + @Inject(BookingRequestMailerService) + private readonly mailer: BookingRequestMailerService, + ) {} + + async list(dto: ListBookingRequestsDto) { + const conditions = [eq(bookingRequests.propertyId, dto.propertyId)]; + if (dto.status) conditions.push(eq(bookingRequests.status, dto.status)); + if (dto.arrivalDateFrom) { + conditions.push(gte(bookingRequests.arrivalDate, dto.arrivalDateFrom)); + } + if (dto.arrivalDateTo) { + conditions.push(lte(bookingRequests.arrivalDate, dto.arrivalDateTo)); + } + if (dto.departureDateFrom) { + conditions.push(gte(bookingRequests.departureDate, dto.departureDateFrom)); + } + if (dto.departureDateTo) { + conditions.push(lte(bookingRequests.departureDate, dto.departureDateTo)); + } + const guestQuery = dto.guest?.trim(); + if (guestQuery) { + conditions.push(or( + ilike(bookingRequests.guestFirstName, `%${guestQuery}%`), + ilike(bookingRequests.guestLastName, `%${guestQuery}%`), + ilike(bookingRequests.guestEmail, `%${guestQuery}%`), + )!); + } + if (dto.hasCard === true) { + conditions.push(isNotNull(bookingRequests.stripePaymentMethodId)); + } else if (dto.hasCard === false) { + conditions.push(isNull(bookingRequests.stripePaymentMethodId)); + } + + const page = dto.page ?? 1; + const limit = dto.limit ?? 20; + const offset = (page - 1) * limit; + const where = and(...conditions); + const direction = dto.sortOrder === 'asc' ? asc : desc; + const orderBy = dto.sortBy === 'requestedTotal' + ? [direction(bookingRequests.submittedTotal), direction(bookingRequests.id)] + : dto.sortBy === 'arrivalDate' + ? [direction(bookingRequests.arrivalDate), direction(bookingRequests.id)] + : dto.sortBy === 'guestName' + ? [ + direction(bookingRequests.guestLastName), + direction(bookingRequests.guestFirstName), + direction(bookingRequests.id), + ] + : dto.sortBy === 'status' + ? [direction(bookingRequests.status), direction(bookingRequests.id)] + : [direction(bookingRequests.createdAt), direction(bookingRequests.id)]; + const [selected, countRows] = await Promise.all([ + this.db + .select() + .from(bookingRequests) + .where(where) + .orderBy(...orderBy) + .limit(limit) + .offset(offset), + this.db + .select({ count: count() }) + .from(bookingRequests) + .where(where), + ]); + // The SQL predicate is authoritative. The final check is deliberate + // defense-in-depth for adapters/test doubles that return an over-broad rowset. + const data = selected + .filter((row) => row.propertyId === dto.propertyId) + .map(toBookingRequestListItem); + const total = Number(countRows[0]?.count ?? 0); + return { data, total, page, limit, hasMore: offset + data.length < total }; + } + + async findById(id: string, propertyId: string) { + const request = await this.findRequest(this.db, id, propertyId); + const operationalReservation = request.status === 'accepted' + ? await this.findLinkedReservation(this.db, request, propertyId) + : null; + return toBookingRequestDetail(request, operationalReservation); + } + + async auditHistory( + id: string, + propertyId: string, + pagination: { limit?: number; cursor?: string } = {}, + ) { + await this.findRequest(this.db, id, propertyId); + const [installments, allocations, paymentRows, resolutions, emails] = await Promise.all([ + this.db.select({ id: bookingRequestInstallments.id }) + .from(bookingRequestInstallments) + .where(and( + eq(bookingRequestInstallments.propertyId, propertyId), + eq(bookingRequestInstallments.bookingRequestId, id), + )), + this.db.select({ id: bookingRequestPaymentAllocations.id }) + .from(bookingRequestPaymentAllocations) + .where(and( + eq(bookingRequestPaymentAllocations.propertyId, propertyId), + eq(bookingRequestPaymentAllocations.bookingRequestId, id), + )), + this.db.select({ id: payments.id }) + .from(payments) + .where(and(eq(payments.propertyId, propertyId), eq(payments.bookingRequestId, id))), + this.db.select({ id: bookingRequestPaymentResolutions.id }) + .from(bookingRequestPaymentResolutions) + .where(and( + eq(bookingRequestPaymentResolutions.propertyId, propertyId), + eq(bookingRequestPaymentResolutions.bookingRequestId, id), + )), + this.db.select({ id: bookingRequestEmailDeliveries.id }) + .from(bookingRequestEmailDeliveries) + .where(and( + eq(bookingRequestEmailDeliveries.propertyId, propertyId), + eq(bookingRequestEmailDeliveries.bookingRequestId, id), + )), + ]); + const entityConditions = [ + eq(auditLogs.bookingRequestId, id), + and(eq(auditLogs.entityType, 'booking_request'), eq(auditLogs.entityId, id)), + ...(installments.length ? [and( + eq(auditLogs.entityType, 'booking_request_installment'), + inArray(auditLogs.entityId, installments.map((row) => row.id)), + )] : []), + ...(allocations.length ? [and( + eq(auditLogs.entityType, 'booking_request_payment_allocation'), + inArray(auditLogs.entityId, allocations.map((row) => row.id)), + )] : []), + ...(paymentRows.length ? [and( + eq(auditLogs.entityType, 'payment'), + inArray(auditLogs.entityId, paymentRows.map((row) => row.id)), + )] : []), + ...(resolutions.length ? [and( + eq(auditLogs.entityType, 'booking_request_payment_resolution'), + inArray(auditLogs.entityId, resolutions.map((row) => row.id)), + )] : []), + ...(emails.length ? [and( + eq(auditLogs.entityType, 'booking_request_email_delivery'), + inArray(auditLogs.entityId, emails.map((row) => row.id)), + )] : []), + ].filter((condition): condition is NonNullable => condition != null); + const allowedEntityTypes = [ + 'booking_request', + 'booking_request_installment', + 'booking_request_payment_allocation', + 'payment', + 'booking_request_payment_resolution', + 'booking_request_email_delivery', + 'reservation', + ]; + const limit = Math.max(1, Math.min(pagination.limit ?? 50, 100)); + const cursor = decodeBookingRequestAuditCursor(pagination.cursor); + const selected = await this.db + .select() + .from(auditLogs) + .where(and( + eq(auditLogs.propertyId, propertyId), + inArray(auditLogs.entityType, allowedEntityTypes), + or(...entityConditions), + cursor + ? lt(auditLogs.timelineSequence, BigInt(cursor.timelineSequence)) + : undefined, + )) + .orderBy(desc(auditLogs.timelineSequence)) + .limit(limit + 1); + const rows = selected.filter((row) => row.propertyId === propertyId); + const relatedEntities = new Set([ + `booking_request:${id}`, + ...installments.map((row) => `booking_request_installment:${row.id}`), + ...allocations.map((row) => `booking_request_payment_allocation:${row.id}`), + ...paymentRows.map((row) => `payment:${row.id}`), + ...resolutions.map((row) => `booking_request_payment_resolution:${row.id}`), + ...emails.map((row) => `booking_request_email_delivery:${row.id}`), + ]); + const allowedEntityTypeSet = new Set(allowedEntityTypes); + const pageRows = rows + .filter((row) => allowedEntityTypeSet.has(row.entityType) + && ( + row.bookingRequestId === id + || relatedEntities.has(`${row.entityType}:${row.entityId ?? ''}`) + ) + && auditRowPrecedesCursor(row, cursor)) + .sort((left, right) => { + return right.timelineSequence === left.timelineSequence + ? 0 + : right.timelineSequence > left.timelineSequence ? 1 : -1; + }); + const data = pageRows.slice(0, limit).map(toBookingRequestAuditHistoryItem); + return { + data, + nextCursor: pageRows.length > limit + ? encodeBookingRequestAuditCursor(pageRows[limit - 1]!) + : null, + }; + } + + async acceptancePreview(id: string, propertyId: string) { + const request = await this.findRequest(this.db, id, propertyId); + if (request.status !== 'pending') { + throw new ConflictException('Only pending booking requests can be previewed'); + } + const currentQuote = await this.bookingEngineService.quote(propertyId, { + roomTypeId: request.roomTypeId, + ratePlanId: request.ratePlanId, + checkIn: request.arrivalDate, + checkOut: request.departureDate, + adults: request.adults, + children: request.children, + serviceIds: request.serviceIds, + }); + const submittedQuote = request.submittedQuoteSnapshot as Record; + const submittedTotal = submittedQuote['grandTotal']; + if (typeof submittedTotal !== 'string' && typeof submittedTotal !== 'number') { + throw new ConflictException('Submitted quote total is unavailable'); + } + if (currentQuote.currencyCode !== request.currencyCode) { + throw new ConflictException('Current quote currency does not match the request'); + } + const currentTotal = String(currentQuote.grandTotal); + return { + requestId: request.id, + submittedTotal: String(submittedTotal), + currentTotal, + currencyCode: request.currencyCode, + previewVersion: 1 as const, + previewToken: acceptancePreviewFingerprint({ + requestId: request.id, + propertyId: request.propertyId, + requestUpdatedAt: request.updatedAt, + currencyCode: request.currencyCode, + currentTotal, + }), + }; + } + + async stayAmendmentPreview( + id: string, + propertyId: string, + dates: PreviewBookingRequestStayAmendmentDto, + ) { + assertCanonicalStayDates(dates.arrivalDate, dates.departureDate); + const request = await this.findRequest(this.db, id, propertyId); + this.assertAcceptedStayAmendmentRequest(request); + const reservation = await this.findLinkedReservation(this.db, request, propertyId); + const preview = await this.buildStayAmendmentPreview( + request, + reservation, + dates, + this.db, + false, + ).catch((error: unknown) => this.throwStayAmendmentError(error)); + const { operationalPreviousPricing: _operationalPreviousPricing, ...response } = preview; + return response; + } + + async amendStay( + id: string, + propertyId: string, + input: AmendBookingRequestStayDto, + actor?: AuditActor, + ): Promise { + assertCanonicalStayDates(input.arrivalDate, input.departureDate); + const idempotencyKey = this.normalizeStayAmendmentIdempotencyKey(input.idempotencyKey); + const reason = input.priceSource === 'custom' ? input.customReason?.trim() : null; + if (input.priceSource === 'custom' && !reason) { + throw new BadRequestException('A reason is required for a custom amended price'); + } + const operationFingerprint = this.stayAmendmentOperationFingerprint( + id, + propertyId, + { ...input, idempotencyKey }, + ); + + const transactionResult = await this.db.transaction(async (tx) => { + // Resolve the immutable accepted reservation link without taking a row + // lock, then acquire the shared pricing/posting mutex first. A poster + // holding that mutex may still need FK key-share locks on property and + // request rows while inserting its charge. Taking those UPDATE locks + // before waiting here would form a real posting-first deadlock. + const requestCandidate = await this.findRequest(tx, id, propertyId); + this.assertAcceptedStayAmendmentRequest(requestCandidate); + return withAcceptedPricingLock( + this.db, + propertyId, + requestCandidate.acceptedReservationId!, + async () => { + await this.lockProperty(tx, propertyId); + const request = await this.lockRequest(tx, id, propertyId); + this.assertAcceptedStayAmendmentRequest(request); + if (request.acceptedReservationId !== requestCandidate.acceptedReservationId) { + throw new ConflictException('Accepted reservation link changed; retry the amendment'); + } + const reservation = await this.lockLinkedReservation(tx, request, propertyId); + + const replay = await this.findExistingStayAmendment( + tx, + propertyId, + id, + idempotencyKey, + operationFingerprint, + ); + if (replay) return { result: this.toStayAmendmentResult(replay), replay: true }; + + await this.reservationService.lockInventory(propertyId, reservation.roomTypeId, tx); + const preview = await this.buildStayAmendmentPreview( + request, + reservation, + input, + tx, + true, + ); + if (input.previewToken !== preview.previewToken) { + throw new ConflictException( + 'Stay amendment preview changed; refresh the quote before applying it', + ); + } + const previousPricing = reservation.acceptedPricingSnapshot as AcceptedPricingSnapshot; + const newPricing = buildAmendedPricingSnapshot({ + source: input.priceSource, + previous: preview.operationalPreviousPricing, + currentQuote: preview.currentQuote, + currencyCode: reservation.currencyCode, + arrivalDate: input.arrivalDate, + departureDate: input.departureDate, + customTotal: input.customTotal, + customReason: reason ?? undefined, + }); + const amendmentId = randomUUID(); + const folioReconciliation = await this.folioService.reconcileAcceptedStayAmendment({ + tx, + amendmentId, + propertyId, + folioId: request.acceptedFolioId!, + reservationId: reservation.id, + previousPricing, + newPricing, + postedBy: actor?.userId ?? null, + }); + const amended = await this.reservationService.modifyAcceptedStay( + reservation, + propertyId, + { + arrivalDate: input.arrivalDate, + departureDate: input.departureDate, + totalAmount: newPricing.grandTotal, + }, + newPricing, + tx, + ); + const createdAt = new Date(); + const amendmentValues = { + id: amendmentId, + propertyId, + bookingRequestId: id, + reservationId: reservation.id, + folioId: request.acceptedFolioId!, + idempotencyKey, + operationFingerprint, + previewToken: input.previewToken, + priceSource: input.priceSource, + previousArrivalDate: reservation.arrivalDate, + previousDepartureDate: reservation.departureDate, + newArrivalDate: input.arrivalDate, + newDepartureDate: input.departureDate, + previousTotalAmount: reservation.totalAmount, + newTotalAmount: newPricing.grandTotal, + currencyCode: reservation.currencyCode, + reason: reason ?? null, + previousPricingSnapshot: structuredClone(previousPricing), + newPricingSnapshot: structuredClone(newPricing), + actorUserId: actor?.userId ?? null, + actorEmail: actor?.userEmail ?? null, + createdAt, + }; + await tx.insert(bookingRequestStayAmendments).values(amendmentValues); + await tx.insert(auditLogs).values({ + propertyId, + bookingRequestId: id, + action: 'update', + entityType: 'reservation', + entityId: reservation.id, + ...actorFields(actor), + previousValue: { + arrivalDate: reservation.arrivalDate, + departureDate: reservation.departureDate, + totalAmount: reservation.totalAmount, + acceptedPricingSnapshot: structuredClone(previousPricing), + }, + newValue: { + amendmentId, + previousArrivalDate: reservation.arrivalDate, + previousDepartureDate: reservation.departureDate, + previousTotalAmount: reservation.totalAmount, + previousPriceSource: previousPricing.source, + arrivalDate: amended.reservation.arrivalDate, + departureDate: amended.reservation.departureDate, + totalAmount: amended.reservation.totalAmount, + priceSource: input.priceSource, + reason: reason ?? null, + acceptedPricingSnapshot: structuredClone(newPricing), + folioReconciliation: structuredClone(folioReconciliation), + }, + description: 'Accepted Booking Request stay amended', + }); + await this.insertConsequence(tx, propertyId, id, `amend:${amendmentId}`, { + event: 'reservation.modified', + entityType: 'reservation', + entityId: reservation.id, + propertyId, + data: { + amendmentId, + bookingRequestId: id, + reservationId: reservation.id, + folioId: request.acceptedFolioId!, + previousArrivalDate: reservation.arrivalDate, + previousDepartureDate: reservation.departureDate, + arrivalDate: amended.reservation.arrivalDate, + departureDate: amended.reservation.departureDate, + roomTypeId: reservation.roomTypeId, + previousTotalAmount: reservation.totalAmount, + totalAmount: amended.reservation.totalAmount, + currencyCode: reservation.currencyCode, + priceSource: input.priceSource, + reason: reason ?? null, + }, + timestamp: createdAt.toISOString(), + }, { audit: false }); + return { + result: this.toStayAmendmentResult(amendmentValues), + replay: false, + }; + }, + tx, + ); + }).catch((error: unknown) => this.throwStayAmendmentError(error)); + + await this.deliverConsequencesBestEffort(id, propertyId); + return transactionResult.result; + } + + /** + * Recover durable consequences after an API process stops between the + * decision commit and dispatch. Claims remain property-scoped and the + * existing lease permits safe recovery of stale processing rows. + */ + async processPendingConsequences(limit = 100): Promise { + const staleBefore = new Date(Date.now() - CONSEQUENCE_CLAIM_LEASE_MS); + const candidates = await this.db + .select() + .from(bookingRequestConsequences) + .where(or( + eq(bookingRequestConsequences.status, 'pending'), + and( + eq(bookingRequestConsequences.status, 'processing'), + lte(bookingRequestConsequences.claimedAt, staleBefore), + ), + )) + .orderBy(bookingRequestConsequences.createdAt) + .limit(Math.max(1, Math.min(limit, 500))); + + const recoverable = candidates.filter((candidate) => + candidate.status === 'pending' + || ( + candidate.status === 'processing' + && candidate.claimedAt != null + && candidate.claimedAt.getTime() <= staleBefore.getTime() + )); + const requests = new Map(); + for (const candidate of recoverable) { + const key = `${candidate.propertyId}:${candidate.bookingRequestId}`; + requests.set(key, { + requestId: candidate.bookingRequestId, + propertyId: candidate.propertyId, + }); + } + for (const request of requests.values()) { + await this.deliverConsequencesBestEffort( + request.requestId, + request.propertyId, + ); + } + return requests.size; + } + + async accept( + id: string, + propertyId: string, + input: AcceptBookingRequestInput | AcceptBookingRequestDto, + actor?: AuditActor, + ) { + const initial = await this.findRequest(this.db, id, propertyId); + if (initial.status === 'accepted') { + const linked = await this.findLinkedReservation(this.db, initial, propertyId); + await this.queueAcceptedEmailBestEffort(initial, propertyId); + await this.deliverConsequencesBestEffort(id, propertyId); + await this.deliverEmailsBestEffort(id, propertyId); + return toAcceptedBookingRequestDecision(initial, linked); + } + if (initial.status === 'denied') { + throw new ConflictException('Cannot accept a denied booking request'); + } + + const result = await this.db.transaction(async (tx) => { + const locked = await this.lockRequest(tx, id, propertyId); + if (locked.status === 'accepted') { + return { + reservation: await this.findLinkedReservation(tx, locked, propertyId), + request: locked, + }; + } + if (locked.status === 'denied') { + throw new ConflictException('Cannot accept a denied booking request'); + } + + assertBookingRequestTransition(locked.status, 'accepted'); + await this.reservationService.lockInventory(propertyId, locked.roomTypeId, tx); + const currentQuote = await this.bookingEngineService.quote(propertyId, { + roomTypeId: locked.roomTypeId, + ratePlanId: locked.ratePlanId, + checkIn: locked.arrivalDate, + checkOut: locked.departureDate, + adults: locked.adults, + children: locked.children, + serviceIds: locked.serviceIds, + }, tx, { lockForUpdate: true }); + const pricing = buildAcceptedPricingSnapshot({ + source: input.priceSource, + requestCurrencyCode: locked.currencyCode, + submittedQuote: locked.submittedQuoteSnapshot, + currentQuote, + customTotal: input.customTotal, + customReason: input.customReason, + }); + // Acceptance sources are narrower than the operational snapshot's later + // amendment-only `prior` source. + const acceptancePriceSource = pricing.source as BookingRequestPriceSource; + const expectedPreviewToken = acceptancePreviewFingerprint({ + requestId: locked.id, + propertyId: locked.propertyId, + requestUpdatedAt: locked.updatedAt, + currencyCode: currentQuote.currencyCode, + currentTotal: String(currentQuote.grandTotal), + }); + if (input.previewToken !== expectedPreviewToken) { + throw new ConflictException( + 'Acceptance preview changed; refresh the quote before accepting', + ); + } + const guest = await this.guestService.create({ + firstName: locked.guestFirstName, + lastName: locked.guestLastName, + email: locked.guestEmail, + phone: locked.guestPhone ?? undefined, + }, tx); + const reservation = await this.reservationService.create({ + propertyId, + guestId: guest.id, + arrivalDate: locked.arrivalDate, + departureDate: locked.departureDate, + roomTypeId: locked.roomTypeId, + ratePlanId: locked.ratePlanId, + totalAmount: pricing.grandTotal, + currencyCode: pricing.currencyCode, + adults: locked.adults, + children: locked.children, + specialRequests: locked.specialRequests ?? undefined, + source: 'direct', + channelCode: 'booking_request', + }, { acceptedPricingSnapshot: pricing }, tx); + const folio = await this.folioService.createAutoFolio({ + id: reservation.id, + propertyId, + bookingId: reservation.bookingId, + guestId: guest.id, + currencyCode: pricing.currencyCode, + }, tx); + + const attachedServices: Array> = []; + for (const serviceId of new Set(locked.serviceIds ?? [])) { + const acceptedService = pricing.services.find((service: AcceptedPricingSnapshot['services'][number]) => + service.serviceId === serviceId); + if (!acceptedService) { + throw new ConflictException( + `Accepted quote has no pricing for selected service ${serviceId}`, + ); + } + attachedServices.push(await this.ancillaryService.attachToReservation( + reservation.id, + { + propertyId, + serviceId, + sourceChannel: 'booking_engine', + unitPrice: acceptedService.unitPrice, + quantity: 1, + }, + tx, + { + currencyCode: acceptedService.currencyCode, + postingRule: acceptedService.postingRule, + chargeType: acceptedService.chargeType, + }, + )); + } + attachedServices.push(...await this.ancillaryService.ensurePackageComponents( + reservation.id, + propertyId, + tx, + { + freezeUnquotedAtZero: true, + currencyCode: pricing.currencyCode, + }, + )); + + const linkedPayments = await tx + .update(payments) + .set({ folioId: folio.id, updatedAt: new Date() }) + .where(and( + eq(payments.propertyId, propertyId), + eq(payments.bookingRequestId, id), + )) + .returning({ id: payments.id }); + if (linkedPayments.length > 0) { + await this.folioService.recalculateBalance(folio.id, propertyId, tx); + } + + const decidedAt = new Date(); + const [updated] = await tx + .update(bookingRequests) + .set({ + status: 'accepted', + currentQuoteSnapshot: structuredClone(currentQuote), + acceptedPriceSource: acceptancePriceSource, + acceptedTotal: pricing.grandTotal, + customPriceReason: pricing.customReason, + acceptedReservationId: reservation.id, + acceptedFolioId: folio.id, + decidedBy: actor?.userId ?? null, + decidedAt, + updatedAt: decidedAt, + }) + .where(and( + eq(bookingRequests.id, id), + eq(bookingRequests.propertyId, propertyId), + eq(bookingRequests.status, 'pending'), + )) + .returning(); + if (!updated) { + throw new ConflictException('Booking request decision changed concurrently'); + } + + const acceptedEvent = { + event: 'booking_request.accepted', + entityType: 'booking_request', + entityId: id, + propertyId, + data: { + requestId: id, + reservationId: reservation.id, + folioId: folio.id, + priceSource: acceptancePriceSource, + acceptedTotal: pricing.grandTotal, + currencyCode: locked.currencyCode, + }, + timestamp: decidedAt.toISOString(), + } satisfies BookingRequestAcceptedWebhook; + await this.insertConsequence( + tx, + propertyId, + id, + ACCEPTED_CONSEQUENCE_KIND, + acceptedEvent, + ); + await this.insertConsequence( + tx, + propertyId, + id, + RESERVATION_CREATED_CONSEQUENCE_KIND, + { + event: 'reservation.created', + entityType: 'reservation', + entityId: reservation.id, + propertyId, + data: { + reservationId: reservation.id, + arrivalDate: reservation.arrivalDate, + departureDate: reservation.departureDate, + roomTypeId: reservation.roomTypeId, + }, + timestamp: decidedAt.toISOString(), + }, + ); + await this.insertConsequence(tx, propertyId, id, FOLIO_CREATED_CONSEQUENCE_KIND, { + event: 'folio.created', + entityType: 'folio', + entityId: folio.id, + propertyId, + data: { folioNumber: folio.folioNumber, type: folio.type }, + timestamp: decidedAt.toISOString(), + }); + for (const attached of attachedServices) { + if (typeof attached['id'] !== 'string') continue; + if (typeof attached['serviceName'] !== 'string') { + throw new ConflictException('Attached service is missing its event snapshot'); + } + await this.insertConsequence(tx, propertyId, id, `service:${attached['id']}`, { + event: 'reservation.service_attached', + entityType: 'reservation_service', + entityId: attached['id'], + propertyId, + data: reservationServiceAttachedPayload( + attached as unknown as Parameters< + typeof reservationServiceAttachedPayload + >[0], + attached['serviceName'], + ), + timestamp: decidedAt.toISOString(), + }); + } + await tx.insert(auditLogs).values({ + propertyId, + bookingRequestId: id, + action: 'update', + entityType: 'booking_request', + entityId: id, + ...actorFields(actor), + previousValue: { status: 'pending' }, + newValue: { + status: 'accepted', + reservationId: reservation.id, + folioId: folio.id, + priceSource: pricing.source, + acceptedTotal: pricing.grandTotal, + customPriceReason: pricing.customReason, + }, + description: 'Booking request accepted', + }); + await this.queueAcceptedEmail(updated, tx); + return { reservation, request: updated }; + }).catch((error: unknown) => this.throwAcceptanceError(error)); + + await this.deliverConsequencesBestEffort(id, propertyId); + await this.deliverEmailsBestEffort(id, propertyId); + return toAcceptedBookingRequestDecision(result.request, result.reservation); + } + + async deny( + id: string, + propertyId: string, + input: DenyBookingRequestDto, + actor?: AuditActor, + ) { + const reason = input.reason?.trim(); + if (!reason) throw new BadRequestException('A denial reason is required'); + + const denied = await this.db.transaction(async (tx) => { + const locked = await this.lockRequest(tx, id, propertyId); + if (locked.status === 'denied') { + return { request: locked, replay: true }; + } + assertBookingRequestTransition(locked.status, 'denied'); + + const movementRows = await tx + .select() + .from(payments) + .where(and( + eq(payments.propertyId, propertyId), + eq(payments.bookingRequestId, id), + )); + const resolutionRows = await tx + .select() + .from(bookingRequestPaymentResolutions) + .where(and( + eq(bookingRequestPaymentResolutions.propertyId, propertyId), + eq(bookingRequestPaymentResolutions.bookingRequestId, id), + )); + const scopedMovements = movementRows.filter((row) => + row.propertyId === propertyId && row.bookingRequestId === id); + const parentMovements = scopedMovements.filter((row) => row.originalPaymentId == null); + if (parentMovements.some((row) => row.status === 'pending')) { + throw new ConflictException( + 'Booking request has a pending payment attempt; retry or resolve it before denial', + ); + } + const scopedResolutions = resolutionRows.filter((row) => + row.propertyId === propertyId && row.bookingRequestId === id); + if (scopedResolutions.some((row) => row.status === 'pending')) { + throw new ConflictException( + 'Booking request has a pending payment resolution; retry it before denial', + ); + } + const parentIds = new Set(parentMovements.map((row) => row.id)); + for (const resolution of scopedResolutions) { + if (!parentIds.has(resolution.paymentId)) { + throw new ConflictException( + `Resolution references unknown captured movement '${resolution.paymentId}'`, + ); + } + if (resolution.type === 'retained' && !resolution.reason?.trim()) { + throw new ConflictException('A reason is required for retained money'); + } + } + for (const payment of parentMovements) { + const summary = summarizeBookingRequestPaymentLedger( + payment, + scopedMovements, + [], + scopedResolutions, + ); + if (summary.unresolved.gt(0)) { + throw new ConflictException( + `Captured movement '${payment.id}' has unresolved money: ${summary.unresolved.toFixed(2)} remains`, + ); + } + } + + const decidedAt = new Date(); + const [updated] = await tx + .update(bookingRequests) + .set({ + status: 'denied', + denialReason: reason, + decidedBy: actor?.userId ?? null, + decidedAt, + updatedAt: decidedAt, + }) + .where(and( + eq(bookingRequests.id, id), + eq(bookingRequests.propertyId, propertyId), + eq(bookingRequests.status, 'pending'), + )) + .returning(); + if (!updated) { + throw new ConflictException('Booking request decision changed concurrently'); + } + const deniedEvent = { + event: 'booking_request.denied', + entityType: 'booking_request', + entityId: id, + propertyId, + data: { requestId: id, status: 'denied' }, + timestamp: decidedAt.toISOString(), + } satisfies BookingRequestDeniedWebhook; + await this.insertConsequence( + tx, + propertyId, + id, + DENIED_CONSEQUENCE_KIND, + deniedEvent, + ); + await tx.insert(auditLogs).values({ + propertyId, + bookingRequestId: id, + action: 'update', + entityType: 'booking_request', + entityId: id, + ...actorFields(actor), + previousValue: { status: 'pending' }, + newValue: { status: 'denied', denialReason: reason }, + description: 'Booking request denied', + }); + await this.queueDeniedEmail(updated, tx); + return { request: updated, replay: false }; + }); + + if (denied.replay) { + await this.queueDeniedEmailBestEffort(denied.request, propertyId); + } + await this.deliverConsequencesBestEffort(id, propertyId); + await this.deliverEmailsBestEffort(id, propertyId); + return toDeniedBookingRequestDecision(denied.request); + } + + async createPaymentMethodSetup( + propertyId: string, + dto: CreateRequestCardSetupDto, + ): Promise<{ + setupIntentId: string; + clientSecret: string; + clientMode: 'mock' | 'stripe'; + }> { + const config = await this.configService.getPublicConfig(propertyId); + this.assertRequestMode(config); + if (config.paymentMethodCollection === 'disabled') { + throw new BadRequestException('Payment method collection is disabled for booking requests'); + } + this.assertCardCollectionCapability(config, true); + + const applicationId = this.normalizeApplicationId(dto.applicationId); + const setupAttemptId = this.normalizeApplicationId(dto.idempotencyKey); + const setup = await this.savedPaymentMethodGateway.createSetup( + dto.guestEmail, + `booking-request:${propertyId}:${setupAttemptId}`, + { propertyId, applicationId }, + ); + return { + setupIntentId: setup.setupIntentId, + clientSecret: setup.clientSecret, + clientMode: setup.clientMode, + }; + } + + async submit( + propertyId: string, + dto: SubmitBookingRequestDto, + ): Promise { + assertCanonicalStayDates(dto.checkIn, dto.checkOut); + const applicationId = this.normalizeApplicationId(dto.idempotencyKey); + const fingerprint = this.submissionFingerprint(propertyId, dto); + const existing = await this.findExistingRequest( + this.db, + propertyId, + applicationId, + ); + if (existing) { + const acknowledgement = this.acknowledgeReplay(existing, fingerprint); + await this.deliverCreatedConsequenceBestEffort(existing.id, propertyId); + await this.deliverEmailsBestEffort(existing.id, propertyId); + return acknowledgement; + } + + const config = await this.configService.getPublicConfig(propertyId); + this.assertRequestMode(config); + this.assertConfiguredOffer(config, dto.roomTypeId, dto.ratePlanId); + + const applicationAnswers = validateApplicationAnswers( + config.formQuestions, + dto.applicationAnswers, + ); + this.assertCardCollectionCapability( + config, + config.paymentMethodCollection === 'required' || Boolean(dto.setupIntentId?.trim()), + ); + this.assertCardPolicyInput(config.paymentMethodCollection, dto); + + await this.ratePlanService.assertSellable( + propertyId, + dto.ratePlanId, + dto.checkIn, + dto.checkOut, + ); + await this.assertAvailable(propertyId, dto); + + const quote = await this.bookingEngineService.quote(propertyId, { + roomTypeId: dto.roomTypeId, + ratePlanId: dto.ratePlanId, + checkIn: dto.checkIn, + checkOut: dto.checkOut, + adults: dto.adults, + children: dto.children, + serviceIds: dto.serviceIds, + }); + this.assertQuoteUsesConfigSnapshot(config, quote); + assertLedgerCurrencySupported(quote.currencyCode); + const card = await this.resolveCard( + config.paymentMethodCollection, + dto, + { propertyId, applicationId }, + ); + + const result = await this.db.transaction(async (tx) => { + const [lockedConfig] = await tx + .select() + .from(bookingEngineConfig) + .where(eq(bookingEngineConfig.propertyId, propertyId)) + .for('update'); + if (!lockedConfig || !this.sameRequestConfig(config, lockedConfig)) { + throw new ConflictException('Booking request configuration changed; retry submission'); + } + + const transactionReplay = await this.findExistingRequest( + tx, + propertyId, + applicationId, + ); + if (transactionReplay) { + this.acknowledgeReplay(transactionReplay, fingerprint); + return { requestId: transactionReplay.id }; + } + + const [request] = await tx + .insert(bookingRequests) + .values({ + propertyId, + submissionIdempotencyKey: applicationId, + submissionFingerprint: fingerprint, + status: 'pending', + arrivalDate: dto.checkIn, + departureDate: dto.checkOut, + roomTypeId: dto.roomTypeId, + ratePlanId: dto.ratePlanId, + adults: dto.adults, + children: dto.children ?? 0, + guestFirstName: dto.guestFirstName, + guestLastName: dto.guestLastName, + guestEmail: dto.guestEmail, + guestPhone: dto.guestPhone ?? null, + specialRequests: dto.specialRequests ?? null, + serviceIds: structuredClone(dto.serviceIds ?? []), + formSnapshot: structuredClone(config.formQuestions), + applicationAnswers: structuredClone(applicationAnswers), + submittedQuoteSnapshot: structuredClone(quote), + submittedTotal: quote.grandTotal, + currentQuoteSnapshot: null, + currencyCode: quote.currencyCode, + ...card, + }) + .onConflictDoNothing() + .returning({ id: bookingRequests.id }); + + if (request) { + const createdPayload = this.createdEventPayload(request.id, propertyId); + await tx.insert(bookingRequestConsequences).values({ + propertyId, + bookingRequestId: request.id, + kind: CREATED_CONSEQUENCE_KIND, + payload: structuredClone(createdPayload) as unknown as Record< + string, + unknown + >, + status: 'pending', + attempts: 0, + }); + await tx.insert(auditLogs).values({ + propertyId, + bookingRequestId: request.id, + action: 'create', + entityType: 'booking_request', + entityId: request.id, + description: 'Webhook event: booking_request.created', + newValue: structuredClone(createdPayload), + }); + await this.queueReceiptEmail({ + id: request.id, + propertyId, + guestFirstName: dto.guestFirstName, + guestEmail: dto.guestEmail, + arrivalDate: dto.checkIn, + departureDate: dto.checkOut, + }, tx); + return { requestId: request.id }; + } + + const concurrent = await this.findExistingRequest( + tx, + propertyId, + applicationId, + ); + if (!concurrent) { + throw new ConflictException('Payment method setup has already been used'); + } + this.acknowledgeReplay(concurrent, fingerprint); + return { requestId: concurrent.id }; + }); + + await this.deliverCreatedConsequenceBestEffort(result.requestId, propertyId); + await this.deliverEmailsBestEffort(result.requestId, propertyId); + return this.acknowledgement(result.requestId); + } + + private assertRequestMode(config: PublicRequestConfig): void { + if (!config.isEnabled) { + throw new ForbiddenException('Direct booking is not enabled for this property'); + } + if (config.bookingMode !== 'request') { + throw new ForbiddenException('Booking requests are not enabled for this property'); + } + } + + private assertConfiguredOffer( + config: PublicRequestConfig, + roomTypeId: string, + ratePlanId: string, + ): void { + if (!config.sellableRoomTypeIds.includes(roomTypeId)) { + throw new BadRequestException('This room type is not available for direct booking'); + } + if (!config.sellableRatePlanIds.includes(ratePlanId)) { + throw new BadRequestException('This rate is not available for direct booking'); + } + } + + private assertCardCollectionCapability( + config: PublicRequestConfig, + collectingCard: boolean, + ): void { + if (!collectingCard) return; + + if (config.paymentMethodClientMode === 'unsupported') { + throw new BadRequestException('Payment method collection is unavailable for this property'); + } + if ((config.paymentMethodClientMode ?? 'stripe') === 'stripe' + && !config.stripePublishableKey?.trim()) { + throw new BadRequestException('Payment method collection is unavailable for this property'); + } + } + + private assertCardPolicyInput( + policy: PaymentMethodCollection, + dto: SubmitBookingRequestDto, + ): void { + const hasSetup = Boolean(dto.setupIntentId?.trim()); + const hasConsentData = dto.consentAccepted !== undefined + || dto.consentText !== undefined + || dto.consentVersion !== undefined; + + if (policy === 'disabled') { + if (hasSetup || hasConsentData) { + throw new BadRequestException('Payment method collection is disabled for booking requests'); + } + return; + } + + if (!hasSetup) { + if (policy === 'required') { + throw new BadRequestException('A saved payment method is required for this booking request'); + } + if (hasConsentData) { + throw new BadRequestException('Card consent requires a saved payment method'); + } + return; + } + + if ( + dto.consentAccepted !== true + || !dto.consentText?.trim() + || !dto.consentVersion?.trim() + ) { + throw new BadRequestException( + 'Explicit versioned consent is required to save a payment method', + ); + } + } + + private async resolveCard( + policy: PaymentMethodCollection, + dto: SubmitBookingRequestDto, + provenance: { propertyId: string; applicationId: string }, + ): Promise { + if (policy === 'disabled' || !dto.setupIntentId) { + return this.emptyCardSnapshot(); + } + + let savedMethod: SavedPaymentMethod; + try { + savedMethod = await this.savedPaymentMethodGateway.resolveSetup( + dto.setupIntentId, + provenance, + ); + } catch { + throw new BadRequestException('Payment method setup is incomplete or invalid'); + } + + return { + setupIntentId: savedMethod.setupIntentId, + stripeCustomerId: savedMethod.customerId, + stripePaymentMethodId: savedMethod.paymentMethodId, + cardLastFour: savedMethod.cardLastFour, + cardBrand: savedMethod.cardBrand, + consentText: dto.consentText!.trim(), + consentVersion: dto.consentVersion!.trim(), + consentedAt: new Date(), + }; + } + + private emptyCardSnapshot(): CardSnapshot { + return { + setupIntentId: null, + stripeCustomerId: null, + stripePaymentMethodId: null, + cardLastFour: null, + cardBrand: null, + consentText: null, + consentVersion: null, + consentedAt: null, + }; + } + + private async assertAvailable( + propertyId: string, + dto: Pick, + ): Promise { + const availability = await this.availabilityService.searchAvailability( + propertyId, + dto.checkIn, + dto.checkOut, + dto.roomTypeId, + ); + const byDate = new Map( + availability + .filter((row) => row.roomTypeId === dto.roomTypeId) + .map((row) => [row.date, row.available]), + ); + + for (const date of this.stayDates(dto.checkIn, dto.checkOut)) { + if ((byDate.get(date) ?? 0) <= 0) { + throw new ConflictException( + 'No availability for the requested room type and dates; use the waitlist instead', + ); + } + } + } + + private stayDates(checkIn: string, checkOut: string): string[] { + const dates: string[] = []; + const current = new Date(`${checkIn}T00:00:00.000Z`); + const departure = new Date(`${checkOut}T00:00:00.000Z`); + while (current < departure) { + dates.push(current.toISOString().slice(0, 10)); + current.setUTCDate(current.getUTCDate() + 1); + } + return dates; + } + + private normalizeApplicationId(value: string): string { + const normalized = value?.trim(); + if (!normalized || normalized.length > 200) { + throw new BadRequestException('A valid submission idempotency key is required'); + } + return normalized; + } + + private submissionFingerprint( + propertyId: string, + dto: SubmitBookingRequestDto, + ): string { + const payload = { + propertyId, + roomTypeId: dto.roomTypeId, + ratePlanId: dto.ratePlanId, + checkIn: dto.checkIn, + checkOut: dto.checkOut, + guestFirstName: dto.guestFirstName, + guestLastName: dto.guestLastName, + guestEmail: dto.guestEmail, + guestPhone: dto.guestPhone ?? null, + adults: dto.adults, + children: dto.children ?? 0, + specialRequests: dto.specialRequests ?? null, + serviceIds: dto.serviceIds ?? [], + applicationAnswers: dto.applicationAnswers, + setupIntentId: dto.setupIntentId?.trim() || null, + consentAccepted: dto.consentAccepted ?? null, + consentText: dto.consentText?.trim() || null, + consentVersion: dto.consentVersion?.trim() || null, + }; + return createHash('sha256').update(this.stableSerialize(payload)).digest('hex'); + } + + private stableSerialize(value: unknown): string { + return stableSerialize(value); + } + + private assertAcceptedStayAmendmentRequest( + request: typeof bookingRequests.$inferSelect, + ): void { + if (request.status !== 'accepted') { + throw new ConflictException('Only accepted booking requests can amend a stay'); + } + if (!request.acceptedReservationId || !request.acceptedFolioId) { + throw new ConflictException( + `Accepted booking request ${request.id} has no linked operational stay`, + ); + } + } + + private async buildStayAmendmentPreview( + request: typeof bookingRequests.$inferSelect, + reservation: typeof reservations.$inferSelect, + dates: Pick, + db: any, + lockForUpdate: boolean, + ) { + assertCanonicalStayDates(dates.arrivalDate, dates.departureDate); + const previousPricing = reservation.acceptedPricingSnapshot as AcceptedPricingSnapshot | null; + if (!previousPricing) { + throw new ConflictException('Linked reservation has no accepted operational pricing basis'); + } + if ( + request.currencyCode !== reservation.currencyCode + || previousPricing.currencyCode !== reservation.currencyCode + ) { + throw new ConflictException('Booking Request and reservation currencies do not match'); + } + const serviceQuery = db + .select({ + id: reservationServices.id, + serviceId: reservationServices.serviceId, + status: reservationServices.status, + sourceChannel: reservationServices.sourceChannel, + createdAt: reservationServices.createdAt, + }) + .from(reservationServices) + .where(and( + eq(reservationServices.propertyId, request.propertyId), + eq(reservationServices.reservationId, reservation.id), + )); + const serviceRows = lockForUpdate && typeof serviceQuery.for === 'function' + ? await serviceQuery.for('update') + : await serviceQuery; + const matchedServiceRows = matchAcceptedReservationServiceRows(previousPricing, serviceRows); + const missingOperationalService = previousPricing.services.find( + (service) => service.postingRule !== 'on_consumption' + && !matchedServiceRows.has(service.serviceId), + ); + if (missingOperationalService) { + throw new ConflictException( + `Accepted service ${missingOperationalService.code} has no linked reservation service`, + ); + } + const cancelledServiceIds = new Set( + previousPricing.services + .map((service) => service.serviceId) + .filter((serviceId) => { + const row = matchedServiceRows.get(serviceId); + return !row || row.status === 'cancelled'; + }), + ); + const operationalPreviousPricing = withoutCancelledAcceptedServices( + previousPricing, + cancelledServiceIds, + ); + const currentQuote = await this.bookingEngineService.quote(request.propertyId, { + roomTypeId: reservation.roomTypeId, + ratePlanId: reservation.ratePlanId, + checkIn: dates.arrivalDate, + checkOut: dates.departureDate, + adults: reservation.adults, + children: reservation.children, + serviceIds: operationalPreviousPricing.services.map((service) => service.serviceId), + }, lockForUpdate ? db : undefined, lockForUpdate + ? { lockForUpdate: true, excludeReservationId: reservation.id } + : { excludeReservationId: reservation.id }); + const priorPricing = buildPriorAmendedPricingSnapshot( + operationalPreviousPricing, + dates.arrivalDate, + dates.departureDate, + ); + const currentPricing = buildAmendedPricingSnapshot({ + source: 'current', + previous: operationalPreviousPricing, + currentQuote, + currencyCode: reservation.currencyCode, + arrivalDate: dates.arrivalDate, + departureDate: dates.departureDate, + }); + const previewToken = amendmentPreviewFingerprint({ + requestId: request.id, + propertyId: request.propertyId, + reservationId: reservation.id, + reservationUpdatedAt: reservation.updatedAt, + previousArrivalDate: reservation.arrivalDate, + previousDepartureDate: reservation.departureDate, + previousTotal: reservation.totalAmount, + previousPricing: operationalPreviousPricing, + arrivalDate: dates.arrivalDate, + departureDate: dates.departureDate, + currentQuote, + }); + return { + requestId: request.id, + reservationId: reservation.id, + previousArrivalDate: reservation.arrivalDate, + previousDepartureDate: reservation.departureDate, + previousTotal: operationalPreviousPricing.grandTotal, + arrivalDate: dates.arrivalDate, + departureDate: dates.departureDate, + priorTotal: priorPricing.grandTotal, + currentTotal: currentPricing.grandTotal, + currencyCode: reservation.currencyCode, + priorPricing, + currentPricing, + currentQuote, + operationalPreviousPricing, + previewVersion: 1 as const, + previewToken, + }; + } + + private normalizeStayAmendmentIdempotencyKey(value: string): string { + const normalized = value?.trim(); + if (!normalized || normalized.length > 200) { + throw new BadRequestException('A valid stay amendment idempotency key is required'); + } + return normalized; + } + + private stayAmendmentOperationFingerprint( + requestId: string, + propertyId: string, + input: AmendBookingRequestStayDto, + ): string { + if (!['prior', 'current', 'custom'].includes(input.priceSource)) { + throw new BadRequestException('A valid stay amendment price source is required'); + } + return createHash('sha256').update(stableSerialize({ + version: 1, + requestId, + propertyId, + arrivalDate: input.arrivalDate, + departureDate: input.departureDate, + priceSource: input.priceSource, + previewToken: input.previewToken, + customTotal: input.customTotal ?? null, + customReason: input.customReason?.trim() || null, + })).digest('hex'); + } + + private async lockProperty(tx: any, propertyId: string) { + const candidates = await tx + .select() + .from(properties) + .where(eq(properties.id, propertyId)) + .for('update'); + const property = candidates.find((candidate: typeof properties.$inferSelect) => + candidate.id === propertyId); + if (!property) throw new NotFoundException(`Property ${propertyId} not found`); + return property; + } + + private async lockLinkedReservation( + tx: any, + request: typeof bookingRequests.$inferSelect, + propertyId: string, + ): Promise { + const candidates = await tx + .select() + .from(reservations) + .where(and( + eq(reservations.id, request.acceptedReservationId!), + eq(reservations.propertyId, propertyId), + )) + .for('update'); + const reservation = candidates.find((candidate: typeof reservations.$inferSelect) => + candidate.id === request.acceptedReservationId + && candidate.propertyId === propertyId); + if (!reservation) { + throw new ConflictException( + `Accepted booking request ${request.id} has no recoverable reservation`, + ); + } + return reservation; + } + + private async findExistingStayAmendment( + db: any, + propertyId: string, + requestId: string, + idempotencyKey: string, + operationFingerprint: string, + ): Promise { + const candidates = await db + .select() + .from(bookingRequestStayAmendments) + .where(and( + eq(bookingRequestStayAmendments.propertyId, propertyId), + or( + eq(bookingRequestStayAmendments.idempotencyKey, idempotencyKey), + and( + eq(bookingRequestStayAmendments.bookingRequestId, requestId), + eq(bookingRequestStayAmendments.operationFingerprint, operationFingerprint), + ), + ), + )); + const scoped = candidates.filter((candidate: typeof bookingRequestStayAmendments.$inferSelect) => + candidate.propertyId === propertyId); + const keyMatch = scoped.find((candidate: typeof bookingRequestStayAmendments.$inferSelect) => + candidate.idempotencyKey === idempotencyKey); + if (keyMatch && ( + keyMatch.bookingRequestId !== requestId + || keyMatch.operationFingerprint !== operationFingerprint + )) { + throw new ConflictException('Stay amendment idempotency key was already used'); + } + return keyMatch ?? scoped.find((candidate: typeof bookingRequestStayAmendments.$inferSelect) => + candidate.bookingRequestId === requestId + && candidate.operationFingerprint === operationFingerprint); + } + + private toStayAmendmentResult( + amendment: Pick< + typeof bookingRequestStayAmendments.$inferSelect, + | 'id' + | 'bookingRequestId' + | 'reservationId' + | 'folioId' + | 'previousArrivalDate' + | 'previousDepartureDate' + | 'newArrivalDate' + | 'newDepartureDate' + | 'previousTotalAmount' + | 'newTotalAmount' + | 'currencyCode' + | 'priceSource' + | 'reason' + >, + ): StayAmendmentResult { + return { + amendmentId: amendment.id, + requestId: amendment.bookingRequestId, + reservationId: amendment.reservationId, + folioId: amendment.folioId, + previousArrivalDate: amendment.previousArrivalDate, + previousDepartureDate: amendment.previousDepartureDate, + arrivalDate: amendment.newArrivalDate, + departureDate: amendment.newDepartureDate, + previousTotalAmount: amendment.previousTotalAmount, + newTotalAmount: amendment.newTotalAmount, + currencyCode: amendment.currencyCode, + priceSource: amendment.priceSource, + reason: amendment.reason, + }; + } + + private throwStayAmendmentError(error: unknown): never { + if (error instanceof BadRequestException && /availability/i.test(error.message)) { + throw new ConflictException(error.message); + } + throw error; + } + + private async findRequest( + db: any, + id: string, + propertyId: string, + ): Promise { + const candidates = await db + .select() + .from(bookingRequests) + .where(and( + eq(bookingRequests.id, id), + eq(bookingRequests.propertyId, propertyId), + )); + const request = candidates.find((candidate: typeof bookingRequests.$inferSelect) => + candidate.id === id && candidate.propertyId === propertyId); + if (!request) { + throw new NotFoundException(`Booking request ${id} not found`); + } + return request; + } + + private async lockRequest( + tx: any, + id: string, + propertyId: string, + ): Promise { + const candidates = await tx + .select() + .from(bookingRequests) + .where(and( + eq(bookingRequests.id, id), + eq(bookingRequests.propertyId, propertyId), + )) + .for('update'); + const request = candidates.find((candidate: typeof bookingRequests.$inferSelect) => + candidate.id === id && candidate.propertyId === propertyId); + if (!request) { + throw new NotFoundException(`Booking request ${id} not found`); + } + return request; + } + + private async findLinkedReservation( + db: any, + request: Pick< + typeof bookingRequests.$inferSelect, + 'id' | 'acceptedReservationId' + >, + propertyId: string, + ): Promise { + if (!request.acceptedReservationId) { + throw new ConflictException( + `Accepted booking request ${request.id} has no linked reservation`, + ); + } + const candidates = await db + .select() + .from(reservations) + .where(and( + eq(reservations.id, request.acceptedReservationId), + eq(reservations.propertyId, propertyId), + )); + const reservation = candidates.find((candidate: typeof reservations.$inferSelect) => + candidate.id === request.acceptedReservationId + && candidate.propertyId === propertyId); + if (!reservation) { + throw new ConflictException( + `Accepted booking request ${request.id} has no recoverable reservation`, + ); + } + return reservation; + } + + private throwAcceptanceError(error: unknown): never { + if ( + error instanceof BadRequestException + && /availability/i.test(error.message) + ) { + throw new ConflictException(error.message); + } + throw error; + } + + private async insertConsequence( + tx: any, + propertyId: string, + requestId: string, + kind: string, + payload: WebhookPayload, + options: { audit?: boolean } = {}, + ): Promise { + const persistedPayload = structuredClone(payload) as unknown as Record< + string, + unknown + >; + await tx.insert(bookingRequestConsequences).values({ + propertyId, + bookingRequestId: requestId, + kind: kind as CreatedConsequence['kind'], + payload: persistedPayload, + status: 'pending', + attempts: 0, + }); + if (options.audit !== false) { + await tx.insert(auditLogs).values({ + propertyId, + bookingRequestId: requestId, + action: 'create', + entityType: payload.entityType, + entityId: payload.entityId, + description: `Webhook event: ${payload.event}`, + newValue: persistedPayload, + }); + } + } + + private async findExistingRequest( + db: Pick, + propertyId: string, + applicationId: string, + ): Promise { + const candidates = await db + .select({ + id: bookingRequests.id, + propertyId: bookingRequests.propertyId, + submissionIdempotencyKey: bookingRequests.submissionIdempotencyKey, + submissionFingerprint: bookingRequests.submissionFingerprint, + }) + .from(bookingRequests) + .where(and( + eq(bookingRequests.propertyId, propertyId), + eq(bookingRequests.submissionIdempotencyKey, applicationId), + )); + return candidates.find((candidate) => + candidate.propertyId === propertyId + && candidate.submissionIdempotencyKey === applicationId); + } + + private acknowledgeReplay( + existing: ExistingRequest, + fingerprint: string, + ): BookingRequestAcknowledgement { + if (existing.submissionFingerprint !== fingerprint) { + throw new ConflictException('Submission idempotency key was already used'); + } + return this.acknowledgement(existing.id); + } + + private acknowledgement(requestId: string): BookingRequestAcknowledgement { + return { + requestId, + status: 'pending', + message: ACKNOWLEDGEMENT_MESSAGE, + }; + } + + private createdEventPayload( + requestId: string, + propertyId: string, + ): BookingRequestCreatedWebhook { + return { + event: 'booking_request.created', + entityType: 'booking_request', + entityId: requestId, + propertyId, + data: { requestId, status: 'pending' }, + timestamp: new Date().toISOString(), + }; + } + + private async queueReceiptEmail( + request: Pick< + typeof bookingRequests.$inferSelect, + 'id' | 'propertyId' | 'guestFirstName' | 'guestEmail' | 'arrivalDate' | 'departureDate' + >, + executor: EmailQueueExecutor, + ): Promise { + const content = requestReceivedEmail({ + guestFirstName: request.guestFirstName, + arrivalDate: request.arrivalDate, + departureDate: request.departureDate, + }); + await this.mailer.queue({ + propertyId: request.propertyId, + bookingRequestId: request.id, + logicalKey: 'request:receipt', + kind: 'receipt', + recipient: request.guestEmail, + ...content, + }, executor); + } + + private async queueAcceptedEmail( + request: Pick< + typeof bookingRequests.$inferSelect, + | 'id' + | 'propertyId' + | 'guestFirstName' + | 'guestEmail' + | 'arrivalDate' + | 'departureDate' + | 'acceptedTotal' + | 'currencyCode' + >, + executor: EmailQueueExecutor, + ): Promise { + if (!request.acceptedTotal) return; + const content = acceptedBookingRequestEmail({ + guestFirstName: request.guestFirstName, + arrivalDate: request.arrivalDate, + departureDate: request.departureDate, + acceptedTotal: request.acceptedTotal, + currencyCode: request.currencyCode, + }); + await this.mailer.queue({ + propertyId: request.propertyId, + bookingRequestId: request.id, + logicalKey: 'decision:accepted', + kind: 'accepted', + recipient: request.guestEmail, + ...content, + }, executor); + } + + private async queueDeniedEmail( + request: Pick< + typeof bookingRequests.$inferSelect, + 'id' | 'propertyId' | 'guestFirstName' | 'guestEmail' | 'arrivalDate' | 'departureDate' + >, + executor: EmailQueueExecutor, + ): Promise { + const content = deniedBookingRequestEmail({ + guestFirstName: request.guestFirstName, + arrivalDate: request.arrivalDate, + departureDate: request.departureDate, + }); + await this.mailer.queue({ + propertyId: request.propertyId, + bookingRequestId: request.id, + logicalKey: 'decision:denied', + kind: 'denied', + recipient: request.guestEmail, + ...content, + }, executor); + } + + private async queueAcceptedEmailBestEffort( + request: Parameters[0], + propertyId: string, + ): Promise { + try { + await this.queueAcceptedEmail(request, this.db); + } catch (error: unknown) { + this.logEmailConsequenceFailure(request.id, propertyId, error); + } + } + + private async queueDeniedEmailBestEffort( + request: Parameters[0], + propertyId: string, + ): Promise { + try { + await this.queueDeniedEmail(request, this.db); + } catch (error: unknown) { + this.logEmailConsequenceFailure(request.id, propertyId, error); + } + } + + private async deliverEmailsBestEffort(requestId: string, propertyId: string): Promise { + try { + await this.mailer.deliverForRequestBestEffort(requestId, propertyId); + } catch (error: unknown) { + this.logEmailConsequenceFailure(requestId, propertyId, error); + } + } + + private logEmailConsequenceFailure( + requestId: string, + _propertyId: string, + error: unknown, + ): void { + this.logger.error( + `Booking request ${requestId} was committed but its email consequence failed`, + error instanceof Error ? error.stack : undefined, + ); + } + + private async deliverCreatedConsequenceBestEffort( + requestId: string, + propertyId: string, + ): Promise { + await this.deliverConsequencesBestEffort(requestId, propertyId); + } + + private async deliverConsequencesBestEffort( + requestId: string, + propertyId: string, + ): Promise { + try { + const candidates = await this.db + .select() + .from(bookingRequestConsequences) + .where(and( + eq(bookingRequestConsequences.propertyId, propertyId), + eq(bookingRequestConsequences.bookingRequestId, requestId), + )); + const pendingKinds = candidates + .filter((candidate) => + candidate.propertyId === propertyId + && candidate.bookingRequestId === requestId + && candidate.status !== 'completed') + .map((candidate) => candidate.kind); + + for (const kind of pendingKinds) { + const consequence = await this.claimConsequence(requestId, propertyId, kind); + if (!consequence) continue; + + try { + await this.webhookService.dispatchPersisted( + consequence.payload as unknown as WebhookPayload, + consequence.id, + ); + } catch (error: unknown) { + await this.recordConsequenceFailure(consequence, error); + this.logger.error( + `Booking request ${requestId} was committed but consequence '${kind}' failed`, + error instanceof Error ? error.stack : undefined, + ); + continue; + } + + const completedAt = new Date(); + await this.db + .update(bookingRequestConsequences) + .set({ + status: 'completed', + claimedAt: null, + lastError: null, + completedAt, + updatedAt: completedAt, + }) + .where(and( + eq(bookingRequestConsequences.id, consequence.id), + eq(bookingRequestConsequences.propertyId, propertyId), + eq(bookingRequestConsequences.status, 'processing'), + eq(bookingRequestConsequences.claimedAt, consequence.claimedAt!), + )); + } + } catch (error: unknown) { + this.logger.error( + `Booking request ${requestId} was committed but consequence state could not be updated`, + error instanceof Error ? error.stack : undefined, + ); + } + } + + private async claimConsequence( + requestId: string, + propertyId: string, + kind: string, + ): Promise { + return this.db.transaction(async (tx) => { + const rows = await tx + .select() + .from(bookingRequestConsequences) + .where(and( + eq(bookingRequestConsequences.propertyId, propertyId), + eq(bookingRequestConsequences.bookingRequestId, requestId), + eq( + bookingRequestConsequences.kind, + kind as CreatedConsequence['kind'], + ), + )) + .for('update'); + const consequence = rows.find((candidate) => + candidate.propertyId === propertyId + && candidate.bookingRequestId === requestId + && candidate.kind === kind); + + if (!consequence || consequence.status === 'completed') return undefined; + if ( + consequence.status === 'processing' + && consequence.claimedAt + && consequence.claimedAt.getTime() > Date.now() - CONSEQUENCE_CLAIM_LEASE_MS + ) { + return undefined; + } + + const attemptedAt = new Date(); + const [claimed] = await tx + .update(bookingRequestConsequences) + .set({ + status: 'processing', + attempts: consequence.attempts + 1, + claimedAt: attemptedAt, + lastAttemptAt: attemptedAt, + lastError: null, + updatedAt: attemptedAt, + }) + .where(and( + eq(bookingRequestConsequences.id, consequence.id), + eq(bookingRequestConsequences.propertyId, propertyId), + )) + .returning(); + return claimed; + }); + } + + private async recordConsequenceFailure( + consequence: CreatedConsequence, + error: unknown, + ): Promise { + const failedAt = new Date(); + const message = error instanceof Error ? error.message : String(error); + await this.db + .update(bookingRequestConsequences) + .set({ + status: 'pending', + claimedAt: null, + lastError: message.slice(0, 2000), + updatedAt: failedAt, + }) + .where(and( + eq(bookingRequestConsequences.id, consequence.id), + eq(bookingRequestConsequences.propertyId, consequence.propertyId), + eq(bookingRequestConsequences.status, 'processing'), + eq(bookingRequestConsequences.claimedAt, consequence.claimedAt!), + )); + } + + private assertQuoteUsesConfigSnapshot( + config: PublicRequestConfig, + quote: { depositPolicy: unknown }, + ): void { + if (this.stableSerialize(config.depositPolicy) !== this.stableSerialize(quote.depositPolicy)) { + throw new ConflictException('Booking request configuration changed; retry submission'); + } + } + + private sameRequestConfig( + initial: PublicRequestConfig, + locked: LockedRequestConfig, + ): boolean { + const lockedFormQuestions = validateQuestionDefinitions( + locked.formQuestions ?? [], + { allowActiveUnsupported: true }, + ) + .filter(isSupportedQuestion) + .filter((question) => question.isActive) + .sort((a, b) => a.order - b.order); + const initialSnapshot = { + propertyId: initial.propertyId, + isEnabled: initial.isEnabled, + bookingMode: initial.bookingMode, + paymentMethodCollection: initial.paymentMethodCollection, + stripePublishableKey: initial.stripePublishableKey, + sellableRoomTypeIds: initial.sellableRoomTypeIds, + sellableRatePlanIds: initial.sellableRatePlanIds, + depositPolicy: initial.depositPolicy, + formQuestions: initial.formQuestions, + }; + const lockedSnapshot = { + propertyId: locked.propertyId, + isEnabled: locked.isEnabled, + bookingMode: locked.bookingMode, + paymentMethodCollection: locked.paymentMethodCollection, + stripePublishableKey: locked.stripePublishableKey, + sellableRoomTypeIds: locked.sellableRoomTypeIds, + sellableRatePlanIds: locked.sellableRatePlanIds, + depositPolicy: locked.depositPolicy, + formQuestions: lockedFormQuestions, + }; + return this.stableSerialize(initialSnapshot) === this.stableSerialize(lockedSnapshot); + } +} diff --git a/apps/api/src/modules/booking-request/dto/accept-booking-request.dto.ts b/apps/api/src/modules/booking-request/dto/accept-booking-request.dto.ts new file mode 100644 index 00000000..bbfa6fab --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/accept-booking-request.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; +import { IsMoneyString } from '../../../common/validation/is-money-string.validator'; + +const BOOKING_REQUEST_PRICE_SOURCES = ['submitted', 'current', 'custom'] as const; + +export class AcceptBookingRequestDto { + @ApiProperty({ enum: BOOKING_REQUEST_PRICE_SOURCES }) + @IsEnum(BOOKING_REQUEST_PRICE_SOURCES) + priceSource!: (typeof BOOKING_REQUEST_PRICE_SOURCES)[number]; + + @ApiProperty({ description: 'Opaque fingerprint returned by the latest acceptance preview' }) + @IsString() + @Matches(/^v1:[a-f0-9]{64}$/) + previewToken!: string; + + @ApiPropertyOptional({ example: '240.00' }) + @IsOptional() + @IsMoneyString() + customTotal?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + customReason?: string; +} diff --git a/apps/api/src/modules/booking-request/dto/amend-booking-request-stay.dto.ts b/apps/api/src/modules/booking-request/dto/amend-booking-request-stay.dto.ts new file mode 100644 index 00000000..8203897a --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/amend-booking-request-stay.dto.ts @@ -0,0 +1,50 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, IsUUID, Matches, MaxLength } from 'class-validator'; +import { IsMoneyString } from '../../../common/validation/is-money-string.validator'; +import { IsCanonicalCalendarDate } from '../booking-request-date.validator'; + +const STAY_AMENDMENT_PRICE_SOURCES = ['prior', 'current', 'custom'] as const; + +class BookingRequestStayAmendmentDatesDto { + @ApiProperty({ example: '2026-10-01' }) + @IsCanonicalCalendarDate() + arrivalDate!: string; + + @ApiProperty({ example: '2026-10-04' }) + @IsCanonicalCalendarDate() + departureDate!: string; +} + +export class PreviewBookingRequestStayAmendmentDto extends BookingRequestStayAmendmentDatesDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + propertyId!: string; +} + +export class AmendBookingRequestStayDto extends BookingRequestStayAmendmentDatesDto { + @ApiProperty({ enum: STAY_AMENDMENT_PRICE_SOURCES }) + @IsEnum(STAY_AMENDMENT_PRICE_SOURCES) + priceSource!: (typeof STAY_AMENDMENT_PRICE_SOURCES)[number]; + + @ApiProperty({ description: 'Opaque fingerprint returned by the latest amendment preview' }) + @IsString() + @Matches(/^v1:[a-f0-9]{64}$/) + previewToken!: string; + + @ApiProperty({ description: 'Durable client-generated operation key', maxLength: 200 }) + @IsString() + @MaxLength(200) + @Matches(/\S/) + idempotencyKey!: string; + + @ApiPropertyOptional({ example: '420.00' }) + @IsOptional() + @IsMoneyString() + customTotal?: string; + + @ApiPropertyOptional({ description: 'Required when priceSource is custom' }) + @IsOptional() + @IsString() + @MaxLength(2000) + customReason?: string; +} diff --git a/apps/api/src/modules/booking-request/dto/booking-request-payment.dto.ts b/apps/api/src/modules/booking-request/dto/booking-request-payment.dto.ts new file mode 100644 index 00000000..a39476df --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/booking-request-payment.dto.ts @@ -0,0 +1,184 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { + ArrayMinSize, + ArrayUnique, + IsArray, + IsDateString, + IsEnum, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, +} from 'class-validator'; +import { IsMoneyString } from '../../../common/validation/is-money-string.validator'; + +export const BOOKING_REQUEST_INSTALLMENT_MILESTONES = [ + 'date', + 'arrival', + 'checkout', + 'manual', +] as const; + +export const BOOKING_REQUEST_EXTERNAL_PAYMENT_METHODS = [ + 'credit_card', + 'debit_card', + 'cash', + 'bank_transfer', + 'pix', + 'other', +] as const; + +export class CreateBookingRequestInstallmentDto { + @ApiProperty({ example: '30% deposit' }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + label!: string; + + @ApiPropertyOptional({ minimum: 0, default: 0 }) + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + + @ApiPropertyOptional({ example: '100.00' }) + @IsOptional() + @IsMoneyString() + fixedAmount?: string; + + @ApiPropertyOptional({ example: '30.00', description: 'Percentage from 0.01 to 100.00' }) + @IsOptional() + @IsMoneyString({ maximum: '100' }) + percentage?: string; + + @ApiProperty({ enum: BOOKING_REQUEST_INSTALLMENT_MILESTONES }) + @IsEnum(BOOKING_REQUEST_INSTALLMENT_MILESTONES) + dueMilestone!: (typeof BOOKING_REQUEST_INSTALLMENT_MILESTONES)[number]; + + @ApiPropertyOptional({ example: '2026-09-01' }) + @IsOptional() + @IsDateString({ strict: true }) + dueDate?: string; +} + +export class UpdateBookingRequestInstallmentDto extends PartialType( + CreateBookingRequestInstallmentDto, +) {} + +export class ReorderBookingRequestInstallmentsDto { + @ApiProperty({ type: [String], description: 'Every request installment ID in target order' }) + @IsArray() + @ArrayMinSize(1) + @ArrayUnique() + @IsUUID('4', { each: true }) + installmentIds!: string[]; +} + +export class AllocateBookingRequestPaymentDto { + @ApiProperty() + @IsUUID() + paymentId!: string; + + @ApiProperty({ example: '50.00' }) + @IsMoneyString() + amount!: string; +} + +export class ChargeBookingRequestCardDto { + @ApiProperty({ example: '50.00' }) + @IsMoneyString() + amount!: string; + + @ApiProperty({ description: 'Stable client-generated identity for this charge' }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + idempotencyKey!: string; +} + +export class RecordBookingRequestExternalPaymentDto { + @ApiProperty({ example: '50.00' }) + @IsMoneyString() + amount!: string; + + @ApiProperty({ example: 'EUR' }) + @IsString() + @IsNotEmpty() + @MaxLength(3) + currencyCode!: string; + + @ApiProperty({ enum: BOOKING_REQUEST_EXTERNAL_PAYMENT_METHODS }) + @IsEnum(BOOKING_REQUEST_EXTERNAL_PAYMENT_METHODS) + method!: (typeof BOOKING_REQUEST_EXTERNAL_PAYMENT_METHODS)[number]; + + @ApiProperty({ description: 'When the externally collected money moved' }) + @IsDateString() + processedAt!: string; + + @ApiPropertyOptional({ example: 'bank' }) + @IsOptional() + @IsString() + @MaxLength(20) + provider?: string; + + @ApiProperty({ description: 'Provider, terminal, bank, or receipt reference' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + reference!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; +} + +export class RefundBookingRequestPaymentDto { + @ApiProperty({ example: '25.00' }) + @IsMoneyString() + amount!: string; + + @ApiProperty({ description: 'Stable client-generated identity for this refund' }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + idempotencyKey!: string; +} + +export class RecordBookingRequestExternalReturnDto { + @ApiProperty({ example: '25.00' }) + @IsMoneyString() + amount!: string; + + @ApiProperty({ description: 'When the external money was returned' }) + @IsDateString() + processedAt!: string; + + @ApiProperty({ description: 'Bank, terminal, or receipt return reference' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + reference!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + notes?: string; +} + +export class RetainBookingRequestPaymentDto { + @ApiProperty({ example: '25.00' }) + @IsMoneyString() + amount!: string; + + @ApiProperty({ description: 'Mandatory business reason for retaining captured money' }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + reason!: string; +} diff --git a/apps/api/src/modules/booking-request/dto/booking-request-response.dto.ts b/apps/api/src/modules/booking-request/dto/booking-request-response.dto.ts new file mode 100644 index 00000000..9880ca25 --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/booking-request-response.dto.ts @@ -0,0 +1,288 @@ +import type { auditLogs, bookingRequests, reservations } from '../booking-request-db.js'; + +type BookingRequestRow = typeof bookingRequests.$inferSelect; +type ReservationRow = typeof reservations.$inferSelect; +type AuditLogRow = typeof auditLogs.$inferSelect; + +export interface BookingRequestListItemDto { + id: string; + propertyId: string; + status: BookingRequestRow['status']; + arrivalDate: string; + departureDate: string; + roomTypeId: string; + ratePlanId: string; + adults: number; + children: number; + guestFirstName: string; + guestLastName: string; + guestEmail: string; + hasCard: boolean; + submittedTotal: string; + currencyCode: string; + acceptedPriceSource: BookingRequestRow['acceptedPriceSource']; + acceptedTotal: string | null; + acceptedReservationId: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface BookingRequestDetailDto extends Omit { + guestPhone: string | null; + specialRequests: string | null; + serviceIds: string[]; + formSnapshot: BookingRequestRow['formSnapshot']; + applicationAnswers: BookingRequestRow['applicationAnswers']; + submittedQuoteSnapshot: unknown; + currentQuoteSnapshot: unknown; + currencyCode: string; + card: { brand: string | null; lastFour: string | null } | null; + customPriceReason: string | null; + acceptedFolioId: string | null; + decidedBy: string | null; + decidedAt: Date | null; + denialReason: string | null; + operationalReservation: { + id: string; + arrivalDate: string; + departureDate: string; + totalAmount: string; + currencyCode: string; + roomTypeId: string; + ratePlanId: string; + status: ReservationRow['status']; + updatedAt: Date; + } | null; +} + +export interface AcceptedBookingRequestDecisionDto { + requestId: string; + status: 'accepted'; + reservationId: string; + folioId: string | null; + totalAmount: string; + currencyCode: string; + priceSource: BookingRequestRow['acceptedPriceSource']; + customReason: string | null; +} + +export interface DeniedBookingRequestDecisionDto { + requestId: string; + status: 'denied'; + denialReason: string; + decidedAt: Date | null; +} + +export interface BookingRequestAuditHistoryItemDto { + source: 'audit_log'; + id: string; + action: string; + actorDisplay: string; + occurredAt: Date; + summary: string; + details: Record; +} + +const AUDIT_DETAIL_KEYS: Record = { + booking_request: [ + 'status', 'reservationId', 'folioId', 'priceSource', 'acceptedTotal', + 'customPriceReason', 'denialReason', + ], + booking_request_installment: [ + 'label', 'sortOrder', 'fixedAmount', 'percentage', 'resolvedAmount', + 'dueMilestone', 'dueDate', 'allocatedAmount', 'status', + ], + booking_request_payment_allocation: [ + 'paymentId', 'installmentId', 'amount', 'reason', + ], + payment: ['folioId', 'amount', 'currencyCode', 'method', 'status', 'result'], + booking_request_payment_resolution: ['paymentId', 'type', 'amount', 'reason', 'status'], + booking_request_email_delivery: ['kind', 'status', 'attempts', 'automaticAttempts', 'mode'], + reservation: [ + 'amendmentId', + 'previousArrivalDate', 'previousDepartureDate', 'previousTotalAmount', 'previousPriceSource', + 'arrivalDate', 'departureDate', 'totalAmount', 'priceSource', 'reason', + ], +}; + +function safeAuditDetails(row: AuditLogRow) { + const source = ( + row.newValue && typeof row.newValue === 'object' + ? row.newValue + : row.previousValue && typeof row.previousValue === 'object' + ? row.previousValue + : {} + ) as Record; + const details: Record = {}; + for (const key of AUDIT_DETAIL_KEYS[row.entityType] ?? []) { + const value = source[key]; + if ( + value === null + || typeof value === 'string' + || typeof value === 'number' + || typeof value === 'boolean' + ) details[key] = value; + } + return details; +} + +function auditSummary( + row: AuditLogRow, + details: Record, +): string { + const known = (value: unknown, allowed: readonly string[], fallback: string) => + typeof value === 'string' && allowed.includes(value) ? value : fallback; + if (row.entityType === 'booking_request') { + return `request.${known(details['status'], ['pending', 'accepted', 'denied'], 'updated')}`; + } + if (row.entityType === 'booking_request_installment') { + return `installment.${row.action === 'create' ? 'created' : row.action === 'delete' ? 'deleted' : 'updated'}`; + } + if (row.entityType === 'booking_request_payment_allocation') { + return `allocation.${row.action === 'delete' ? 'removed' : 'recorded'}`; + } + if (row.entityType === 'payment') { + return `payment.${known( + details['status'], + ['pending', 'captured', 'failed'], + row.action === 'create' ? 'recorded' : 'updated', + )}`; + } + if (row.entityType === 'booking_request_payment_resolution') { + return `resolution.${known( + details['type'], + ['refund', 'external_return', 'retained'], + 'recorded', + )}`; + } + if (row.entityType === 'booking_request_email_delivery') { + return `email.${known( + details['status'], + ['pending', 'processing', 'sent', 'failed'], + row.action === 'create' ? 'queued' : 'updated', + )}`; + } + if (row.entityType === 'reservation') return 'stay.amended'; + return 'request.updated'; +} + +export function toBookingRequestAuditHistoryItem( + row: AuditLogRow, +): BookingRequestAuditHistoryItemDto { + const details = safeAuditDetails(row); + return { + source: 'audit_log', + id: row.id, + action: row.action, + actorDisplay: row.userEmail || (row.userId ? 'Staff' : 'System'), + occurredAt: row.occurredAt, + summary: auditSummary(row, details), + details, + }; +} + +export function toBookingRequestListItem( + row: BookingRequestRow, +): BookingRequestListItemDto { + const submittedQuote = row.submittedQuoteSnapshot as Record; + const submittedTotal = submittedQuote['grandTotal']; + if (typeof submittedTotal !== 'string' && typeof submittedTotal !== 'number') { + throw new TypeError(`Booking request ${row.id} has no submitted quote total`); + } + return { + id: row.id, + propertyId: row.propertyId, + status: row.status, + arrivalDate: row.arrivalDate, + departureDate: row.departureDate, + roomTypeId: row.roomTypeId, + ratePlanId: row.ratePlanId, + adults: row.adults, + children: row.children, + guestFirstName: row.guestFirstName, + guestLastName: row.guestLastName, + guestEmail: row.guestEmail, + hasCard: Boolean(row.stripePaymentMethodId), + submittedTotal: String(submittedTotal), + currencyCode: row.currencyCode, + acceptedPriceSource: row.acceptedPriceSource, + acceptedTotal: row.acceptedTotal, + acceptedReservationId: row.acceptedReservationId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function toBookingRequestDetail( + row: BookingRequestRow, + operationalReservation?: ReservationRow | null, +): BookingRequestDetailDto { + const { hasCard: _hasCard, ...summary } = toBookingRequestListItem(row); + void _hasCard; + return { + ...summary, + guestPhone: row.guestPhone, + specialRequests: row.specialRequests, + serviceIds: row.serviceIds, + formSnapshot: row.formSnapshot, + applicationAnswers: row.applicationAnswers, + submittedQuoteSnapshot: row.submittedQuoteSnapshot, + currentQuoteSnapshot: row.currentQuoteSnapshot, + card: row.cardBrand || row.cardLastFour + ? { brand: row.cardBrand, lastFour: row.cardLastFour } + : null, + customPriceReason: row.customPriceReason, + acceptedFolioId: row.acceptedFolioId, + decidedBy: row.decidedBy, + decidedAt: row.decidedAt, + denialReason: row.denialReason, + operationalReservation: operationalReservation + ? { + id: operationalReservation.id, + arrivalDate: operationalReservation.arrivalDate, + departureDate: operationalReservation.departureDate, + totalAmount: operationalReservation.totalAmount, + currencyCode: operationalReservation.currencyCode, + roomTypeId: operationalReservation.roomTypeId, + ratePlanId: operationalReservation.ratePlanId, + status: operationalReservation.status, + updatedAt: operationalReservation.updatedAt, + } + : null, + }; +} + +export function toAcceptedBookingRequestDecision( + request: Pick< + BookingRequestRow, + | 'id' + | 'acceptedFolioId' + | 'acceptedTotal' + | 'currencyCode' + | 'acceptedPriceSource' + | 'customPriceReason' + >, + reservation: Pick, +): AcceptedBookingRequestDecisionDto { + return { + requestId: request.id, + status: 'accepted', + reservationId: reservation.id, + folioId: request.acceptedFolioId, + totalAmount: request.acceptedTotal ?? reservation.totalAmount, + currencyCode: reservation.currencyCode ?? request.currencyCode, + priceSource: request.acceptedPriceSource, + customReason: request.customPriceReason, + }; +} + +export function toDeniedBookingRequestDecision( + request: Pick, +): DeniedBookingRequestDecisionDto { + return { + requestId: request.id, + status: 'denied', + denialReason: request.denialReason ?? '', + decidedAt: request.decidedAt, + }; +} diff --git a/apps/api/src/modules/booking-request/dto/create-request-card-setup.dto.ts b/apps/api/src/modules/booking-request/dto/create-request-card-setup.dto.ts new file mode 100644 index 00000000..e37beb12 --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/create-request-card-setup.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateRequestCardSetupDto { + @ApiProperty({ example: 'guest@example.com' }) + @IsEmail() + @MaxLength(255) + guestEmail!: string; + + @ApiProperty({ + description: 'Stable client-generated identity for the booking request application.', + example: 'booking-widget-application-018f6f8f', + }) + @IsString() + @MinLength(1) + @MaxLength(200) + applicationId!: string; + + @ApiProperty({ + description: 'Stable client-generated key for this card setup attempt.', + example: 'request-card-attempt-018f6f8f', + }) + @IsString() + @MinLength(1) + @MaxLength(200) + idempotencyKey!: string; +} diff --git a/apps/api/src/modules/booking-request/dto/deny-booking-request.dto.ts b/apps/api/src/modules/booking-request/dto/deny-booking-request.dto.ts new file mode 100644 index 00000000..d5ff0110 --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/deny-booking-request.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class DenyBookingRequestDto { + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(2000) + reason!: string; +} diff --git a/apps/api/src/modules/booking-request/dto/list-booking-request-audit.dto.ts b/apps/api/src/modules/booking-request/dto/list-booking-request-audit.dto.ts new file mode 100644 index 00000000..5a5ae0b1 --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/list-booking-request-audit.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; + +export class ListBookingRequestAuditDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + propertyId!: string; + + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 50 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit = 50; + + @ApiPropertyOptional({ description: 'Opaque keyset cursor from the previous page' }) + @IsOptional() + @IsString() + @MaxLength(1000) + cursor?: string; +} diff --git a/apps/api/src/modules/booking-request/dto/list-booking-requests.dto.ts b/apps/api/src/modules/booking-request/dto/list-booking-requests.dto.ts new file mode 100644 index 00000000..72432812 --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/list-booking-requests.dto.ts @@ -0,0 +1,92 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsDateString, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +const BOOKING_REQUEST_STATUSES = ['pending', 'accepted', 'denied'] as const; +export const BOOKING_REQUEST_SORT_FIELDS = [ + 'createdAt', + 'requestedTotal', + 'arrivalDate', + 'guestName', + 'status', +] as const; +export const BOOKING_REQUEST_SORT_ORDERS = ['asc', 'desc'] as const; + +export class ListBookingRequestsDto { + @ApiProperty({ description: 'Property ID (required for tenant scoping)' }) + @IsUUID() + propertyId!: string; + + @ApiPropertyOptional({ enum: BOOKING_REQUEST_STATUSES }) + @IsOptional() + @IsEnum(BOOKING_REQUEST_STATUSES) + status?: (typeof BOOKING_REQUEST_STATUSES)[number]; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + arrivalDateFrom?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + arrivalDateTo?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + departureDateFrom?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + departureDateTo?: string; + + @ApiPropertyOptional({ description: 'Case-insensitive name or email search' }) + @IsOptional() + @IsString() + @MaxLength(255) + guest?: string; + + @ApiPropertyOptional({ type: Boolean }) + @IsOptional() + @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) + @IsBoolean() + hasCard?: boolean; + + @ApiPropertyOptional({ enum: BOOKING_REQUEST_SORT_FIELDS, default: 'createdAt' }) + @IsOptional() + @IsEnum(BOOKING_REQUEST_SORT_FIELDS) + sortBy?: (typeof BOOKING_REQUEST_SORT_FIELDS)[number] = 'createdAt'; + + @ApiPropertyOptional({ enum: BOOKING_REQUEST_SORT_ORDERS, default: 'desc' }) + @IsOptional() + @IsEnum(BOOKING_REQUEST_SORT_ORDERS) + sortOrder?: (typeof BOOKING_REQUEST_SORT_ORDERS)[number] = 'desc'; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/apps/api/src/modules/booking-request/dto/submit-booking-request.dto.ts b/apps/api/src/modules/booking-request/dto/submit-booking-request.dto.ts new file mode 100644 index 00000000..87f80601 --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/submit-booking-request.dto.ts @@ -0,0 +1,136 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsArray, + ArrayUnique, + IsBoolean, + IsEmail, + IsInt, + IsObject, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { + IsAfterCheckIn, + IsCanonicalCalendarDate, +} from '../booking-request-date.validator'; + +/** + * Public Booking Request input. Property scope and every persisted price/card + * detail are resolved by the server; the client supplies only selection, + * application, consent, and a SetupIntent reference. + */ +export class SubmitBookingRequestDto { + @ApiProperty({ + description: 'Stable client-generated identifier for replay-safe submission.', + example: 'booking-widget-attempt-018f5f0c', + }) + @IsString() + @MinLength(1) + @MaxLength(200) + idempotencyKey!: string; + + @ApiProperty() + @IsUUID() + roomTypeId!: string; + + @ApiProperty() + @IsUUID() + ratePlanId!: string; + + @ApiProperty({ example: '2026-10-01' }) + @IsCanonicalCalendarDate() + checkIn!: string; + + @ApiProperty({ example: '2026-10-03' }) + @IsCanonicalCalendarDate() + @IsAfterCheckIn() + checkOut!: string; + + @ApiProperty({ example: 'Ada' }) + @IsString() + @MaxLength(100) + guestFirstName!: string; + + @ApiProperty({ example: 'Lovelace' }) + @IsString() + @MaxLength(100) + guestLastName!: string; + + @ApiProperty({ example: 'ada@example.com' }) + @IsEmail() + @MaxLength(255) + guestEmail!: string; + + @ApiPropertyOptional({ example: '+34 600 000 000' }) + @IsOptional() + @IsString() + @MaxLength(50) + guestPhone?: string; + + @ApiProperty({ example: 2 }) + @IsInt() + @Min(1) + adults!: number; + + @ApiPropertyOptional({ default: 0 }) + @IsOptional() + @IsInt() + @Min(0) + children?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + specialRequests?: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @ArrayUnique() + @IsUUID('4', { each: true }) + serviceIds?: string[]; + + @ApiProperty({ + type: 'object', + additionalProperties: true, + description: 'Answers keyed by the stable configured question id.', + }) + @IsObject() + applicationAnswers!: Record; + + @ApiPropertyOptional({ + description: 'Successful SetupIntent identifier; no raw card data or card metadata.', + }) + @IsOptional() + @IsString() + @MaxLength(255) + setupIntentId?: string; + + @ApiPropertyOptional({ + description: 'Must be true when a payment method is supplied.', + }) + @IsOptional() + @IsBoolean() + consentAccepted?: boolean; + + @ApiPropertyOptional({ + description: 'Exact consent copy displayed when the payment method was saved.', + }) + @IsOptional() + @IsString() + @MaxLength(2000) + consentText?: string; + + @ApiPropertyOptional({ + description: 'Version of the displayed saved-payment-method consent.', + }) + @IsOptional() + @IsString() + @MaxLength(40) + consentVersion?: string; +} diff --git a/apps/api/src/modules/connect/connect-booking.service.ts b/apps/api/src/modules/connect/connect-booking.service.ts index a613da4a..1d696b86 100644 --- a/apps/api/src/modules/connect/connect-booking.service.ts +++ b/apps/api/src/modules/connect/connect-booking.service.ts @@ -10,7 +10,7 @@ import { RatePlanService } from '../rate-plan/rate-plan.service'; import { PolicyService } from '../policy/policy.service'; import type { AgentBookDto } from './dto/agent-book.dto'; import type { AgentModifyDto } from './dto/agent-modify.dto'; -import { randomBytes } from 'crypto'; +import { generateConfirmationNumber } from '../../common/crypto/confirmation-number'; @Injectable() export class ConnectBookingService { @@ -80,7 +80,7 @@ export class ConnectBookingService { // 5. Generate confirmation number. High-entropy (128 bits from randomBytes, // Crockford base32, no ambiguous chars) so it can't be enumerated/guessed — // the confirmation number is itself a bearer credential for the booking. - const confirmationNumber = `HAIP-${generateConfirmationToken()}`; + const confirmationNumber = generateConfirmationNumber(); // 6. Create booking const [booking] = await this.db @@ -553,9 +553,6 @@ export class ConnectBookingService { } } -// Crockford base32 alphabet (no I/L/O/U — unambiguous when read/typed). -const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; - /** * 128 bits of cryptographic randomness (16 random bytes) rendered in Crockford * base32. Unguessable — the confirmation number is a bearer credential for the @@ -563,11 +560,5 @@ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; * ~16 bits of randomness). */ export function generateConfirmationToken(): string { - const bytes = randomBytes(16); - let out = ''; - for (let i = 0; i < bytes.length; i++) { - out += CROCKFORD[bytes[i]! & 0x1f]; - out += CROCKFORD[(bytes[i]! >> 5) & 0x1f]; - } - return out; + return generateConfirmationNumber().slice('HAIP-'.length); } diff --git a/apps/api/src/modules/connect/connect-events.service.spec.ts b/apps/api/src/modules/connect/connect-events.service.spec.ts index db70fc46..26b9ae73 100644 --- a/apps/api/src/modules/connect/connect-events.service.spec.ts +++ b/apps/api/src/modules/connect/connect-events.service.spec.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NotFoundException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { DRIZZLE } from '../../database/database.module'; +import { WebhookService } from '../webhook/webhook.service'; import { ConnectEventsService } from './connect-events.service'; describe('ConnectEventsService', () => { @@ -165,7 +169,12 @@ describe('ConnectEventsService', () => { })); const deliveryService = { enqueue: vi.fn().mockResolvedValue({ id: 'del-1' }) }; - const svc = new ConnectEventsService(mockDb, deliveryService as any); + const svc = new ConnectEventsService( + mockDb, + deliveryService as unknown as ConstructorParameters< + typeof ConnectEventsService + >[1], + ); await svc.handleEvent({ event: 'reservation.created', @@ -187,6 +196,42 @@ describe('ConnectEventsService', () => { ); }); + it('forwards a persisted logical event ID without adding it to the webhook body', async () => { + mockDb.select.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([mockSubscription]), + }), + })); + + const deliveryService = { enqueue: vi.fn().mockResolvedValue({ id: 'del-1' }) }; + const svc = new ConnectEventsService(mockDb, deliveryService as any); + const logicalEventId = 'bbbbbbbb-0000-4000-a000-000000000002'; + + await svc.handleEvent({ + event: 'reservation.created', + entityType: 'reservation', + entityId: 'res-1', + propertyId: 'prop-1', + data: { foo: 'bar' }, + timestamp: '2026-08-24T18:00:00.000Z', + logicalEventId, + }); + + const [deliveryPayload, subscriptionId, forwardedEventId] = + deliveryService.enqueue.mock.calls[0]!; + expect(deliveryPayload).toEqual({ + eventType: 'reservation.created', + propertyId: 'prop-1', + entityType: 'reservation', + entityId: 'res-1', + data: { foo: 'bar' }, + timestamp: '2026-08-24T18:00:00.000Z', + }); + expect(deliveryPayload).not.toHaveProperty('logicalEventId'); + expect(subscriptionId).toBe('sub-1'); + expect(forwardedEventId).toBe(logicalEventId); + }); + it('does nothing when no subscriptions match', async () => { mockDb.select.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ @@ -253,3 +298,33 @@ describe('ConnectEventsService', () => { }); }); }); + +describe('ConnectEventsService durable event delivery', () => { + it('propagates wildcard listener failures to a persisted dispatcher', async () => { + const db = { + select: vi.fn(() => { + throw new Error('subscription lookup unavailable'); + }), + }; + const moduleRef = await Test.createTestingModule({ + imports: [EventEmitterModule.forRoot({ wildcard: true })], + providers: [ + ConnectEventsService, + WebhookService, + { provide: DRIZZLE, useValue: db }, + ], + }).compile(); + await moduleRef.init(); + + await expect(moduleRef.get(WebhookService).dispatchPersisted({ + event: 'booking_request.created', + entityType: 'booking_request', + entityId: 'bbbbbbbb-0000-4000-a000-000000000001', + propertyId: 'aaaaaaaa-0000-4000-a000-000000000001', + data: {}, + timestamp: '2026-08-26T00:00:00.000Z', + }, 'logical-event-1')).rejects.toThrow('subscription lookup unavailable'); + + await moduleRef.close(); + }); +}); diff --git a/apps/api/src/modules/connect/connect-events.service.ts b/apps/api/src/modules/connect/connect-events.service.ts index c840f19d..d25a56da 100644 --- a/apps/api/src/modules/connect/connect-events.service.ts +++ b/apps/api/src/modules/connect/connect-events.service.ts @@ -5,6 +5,7 @@ import { agentWebhookSubscriptions, auditLogs } from '@telivityhaip/database'; import { DRIZZLE } from '../../database/database.module'; import type { CreateSubscriptionDto } from './dto/agent-event-subscription.dto'; import { WebhookDeliveryService } from '../webhook/webhook-delivery.service'; +import type { WebhookPayload } from '../webhook/webhook.service'; @Injectable() export class ConnectEventsService { @@ -172,8 +173,8 @@ export class ConnectEventsService { * Handle all webhook events — match against subscriptions and log delivery. * Listens to all events via wildcard. */ - @OnEvent('**') - async handleEvent(payload: any) { + @OnEvent('**', { suppressErrors: false }) + async handleEvent(payload: WebhookPayload) { if (!payload?.propertyId || !payload?.event) return; // Find matching subscriptions @@ -192,17 +193,23 @@ export class ConnectEventsService { if (events.some((pattern: string) => this.matchesEventPattern(payload.event, pattern))) { if (this.deliveryService) { // Enqueue a real HTTP delivery (HMAC-signed, retried). - await this.deliveryService.enqueue( - { - eventType: payload.event, - propertyId: payload.propertyId, - entityType: payload.entityType, - entityId: payload.entityId, - data: payload.data ?? {}, - timestamp: payload.timestamp ?? new Date().toISOString(), - }, - sub.id, - ); + const deliveryPayload = { + eventType: payload.event, + propertyId: payload.propertyId, + entityType: payload.entityType, + entityId: payload.entityId, + data: payload.data ?? {}, + timestamp: payload.timestamp ?? new Date().toISOString(), + }; + if (payload.logicalEventId) { + await this.deliveryService.enqueue( + deliveryPayload, + sub.id, + payload.logicalEventId, + ); + } else { + await this.deliveryService.enqueue(deliveryPayload, sub.id); + } } else { // Fallback — just log the match (for tests / environments without delivery service). await this.db 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/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..e88fed12 --- /dev/null +++ b/apps/api/src/modules/payment/booking-request-stripe-handler.interface.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { + isBookingRequestsEnabled, + 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..456f7e5d --- /dev/null +++ b/apps/api/src/modules/payment/booking-request-stripe-handler.interface.ts @@ -0,0 +1,67 @@ +import type Stripe from 'stripe'; + +/** Injection token for optional booking-request Stripe webhook handling. */ +export const BOOKING_REQUEST_STRIPE_HANDLER = Symbol('BOOKING_REQUEST_STRIPE_HANDLER'); + +export type BookingRequestStripePaymentRow = { + id: string; + propertyId: string; + folioId: string | null; + bookingRequestId?: string | null; + status: string; + amount: string; + currencyCode: string; + method: string; + gatewayProvider: string | null; + gatewayTransactionId: string | null; + originalPaymentId?: string | null; +}; + +/** + * Optional handler registered by @telivityhaip/booking-requests when + * HAIP_BOOKING_REQUESTS=true. Core Stripe webhook delegates here when + * payment.bookingRequestId is set. + */ +export interface BookingRequestStripeHandler { + handlePaymentIntentSucceeded( + pi: Stripe.PaymentIntent, + payment: BookingRequestStripePaymentRow, + ): Promise; + + handlePaymentIntentFailed( + pi: Stripe.PaymentIntent, + payment: BookingRequestStripePaymentRow, + ): Promise; + + handlePaymentIntentCanceled( + pi: Stripe.PaymentIntent, + payment: BookingRequestStripePaymentRow, + ): Promise; + + handlePaymentIntentProcessing( + pi: Stripe.PaymentIntent, + payment: BookingRequestStripePaymentRow, + ): Promise; + + handlePaymentIntentRequiresAction( + pi: Stripe.PaymentIntent, + payment: BookingRequestStripePaymentRow, + ): Promise; + + handleChargeRefunded( + charge: Stripe.Charge, + payment: BookingRequestStripePaymentRow, + ): Promise; + + handleRefundUpdated(refund: Stripe.Refund): Promise; +} + +export function paymentHasBookingRequestId( + payment: BookingRequestStripePaymentRow, +): payment is BookingRequestStripePaymentRow & { bookingRequestId: string } { + return typeof payment.bookingRequestId === 'string' && payment.bookingRequestId.length > 0; +} + +export function isBookingRequestsEnabled(): boolean { + return process.env['HAIP_BOOKING_REQUESTS'] === 'true'; +} 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..a5d35414 100644 --- a/apps/api/src/modules/payment/interfaces/payment-gateway.interface.ts +++ b/apps/api/src/modules/payment/interfaces/payment-gateway.interface.ts @@ -1,6 +1,8 @@ export interface PaymentGatewayResult { success: boolean; transactionId: string; + /** Provider lifecycle status when an operation can complete asynchronously. */ + providerStatus?: 'succeeded' | 'pending' | 'requires_action' | 'failed' | 'canceled' | 'unknown'; errorMessage?: string; } @@ -11,6 +13,15 @@ export interface PaymentGatewayResult { */ export interface PaymentGatewayCallOptions { idempotencyKey?: string; + /** Required for amount-bearing capture/refund calls outside scale-two currencies. */ + currencyCode?: string; + /** Durable correlation identifiers forwarded to the provider on refund claims. */ + metadata?: { + claimId: string; + propertyId: string; + bookingRequestId: string; + paymentId: string; + }; } export interface PaymentGateway { 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..da2285bc --- /dev/null +++ b/apps/api/src/modules/payment/interfaces/saved-payment-method-gateway.interface.ts @@ -0,0 +1,54 @@ +export type SavedPaymentMethod = { + setupIntentId: string; + customerId: string; + paymentMethodId: string; + cardLastFour: string; + cardBrand: string; +}; + +export type SavedPaymentMethodProvenance = { + propertyId: string; + applicationId: string; +}; + +export type SavedPaymentMethodChargeInput = { + customerId: string; + paymentMethodId: string; + /** Durable HAIP identities used by signed provider webhooks for crash recovery. */ + paymentId: string; + propertyId: string; + bookingRequestId: string; + amount: string; + currencyCode: string; + idempotencyKey: string; +}; + +export type SavedPaymentMethodChargeResult = { + success: boolean; + transactionId: string; + requiresAction: boolean; + /** The provider accepted the request but has not reported a terminal result. */ + indeterminate?: boolean; + providerStatus?: string; + errorMessage?: string; +}; + +export interface SavedPaymentMethodGateway { + createSetup( + email: string, + idempotencyKey: string, + provenance: SavedPaymentMethodProvenance, + ): Promise<{ + setupIntentId: string; + clientSecret: string; + customerId: string; + clientMode: 'mock' | 'stripe'; + }>; + resolveSetup( + setupIntentId: string, + expectedProvenance: SavedPaymentMethodProvenance, + ): Promise; + charge(input: SavedPaymentMethodChargeInput): Promise; +} + +export const SAVED_PAYMENT_METHOD_GATEWAY = Symbol('SAVED_PAYMENT_METHOD_GATEWAY'); 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..7204cb01 100644 --- a/apps/api/src/modules/payment/payment-ledger.ts +++ b/apps/api/src/modules/payment/payment-ledger.ts @@ -42,6 +42,24 @@ export function folioPaymentSumWhere( )!; } +/** 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'), + and( + isNull(payments.originalPaymentId), + inArray(payments.status, [...FOLIO_PARENT_PAYMENT_STATUSES]), + ), + ), + )!; +} + /** Payment rows that count toward property-scoped cash reports (same net model). */ export function reportPaymentSumWhere(propertyId: string): SQL { return and( @@ -65,3 +83,11 @@ export function sumRefundChildren( new Decimal(0), ); } + +/** Exact remaining captured value after canonical negative child movements. */ +export function remainingCapturedAmount( + capturedAmount: string | number, + childRows: Array<{ amount: string | number }>, +): Decimal { + return new Decimal(capturedAmount).minus(sumRefundChildren(childRows)); +} 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 new file mode 100644 index 00000000..3e716d4f --- /dev/null +++ b/apps/api/src/modules/payment/stripe-financial-state.spec.ts @@ -0,0 +1,114 @@ +import { ConflictException } from '@nestjs/common'; +import { + classifyHaipMetadata, + decidePaymentIntentTransition, + decideRefundTransition, + paymentIntentCorrelation, + refundCorrelation, +} from './stripe-financial-state'; + +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', + }, + }); + }); +}); diff --git a/apps/api/src/modules/payment/stripe-financial-state.ts b/apps/api/src/modules/payment/stripe-financial-state.ts new file mode 100644 index 00000000..f1e9c89f --- /dev/null +++ b/apps/api/src/modules/payment/stripe-financial-state.ts @@ -0,0 +1,160 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; + +export type HaipMetadataClassification = + | { ownership: 'external' } + | { ownership: 'owned-valid'; correlation: T } + | { ownership: 'owned-malformed'; error: unknown }; + +export function hasHaipFinancialMetadata( + metadata: Record | null | undefined, +): boolean { + return Object.keys(metadata ?? {}).some((key) => key.startsWith('haip_')); +} + +/** + * Separates Stripe-account traffic from HAIP-owned traffic before any ledger lookup. + * Event-specific correlation parsers remain responsible for exact required fields. + */ +export function classifyHaipMetadata( + metadata: Record | null | undefined, + parseCorrelation: (metadata: Record | null | undefined) => T, +): HaipMetadataClassification { + if (!hasHaipFinancialMetadata(metadata)) return { ownership: 'external' }; + try { + return { ownership: 'owned-valid', correlation: parseCorrelation(metadata) }; + } catch (error) { + return { ownership: 'owned-malformed', error }; + } +} + +export type PaymentIntentEvent = + | 'processing' + | 'succeeded' + | 'payment_failed' + | 'canceled' + | 'requires_action'; + +export type PaymentIntentCorrelation = { + paymentId: string; + propertyId: string; + bookingRequestId: string; +}; + +export function paymentIntentCorrelation( + metadata: Record | null | undefined, +): PaymentIntentCorrelation { + const correlation = { + paymentId: metadata?.['haip_payment_id'], + propertyId: metadata?.['haip_property_id'], + bookingRequestId: metadata?.['haip_booking_request_id'], + }; + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!correlation.paymentId + || !correlation.propertyId + || !correlation.bookingRequestId + || !uuid.test(correlation.paymentId) + || !uuid.test(correlation.propertyId) + || !uuid.test(correlation.bookingRequestId)) { + throw new BadRequestException('Stripe PaymentIntent is missing exact HAIP correlation metadata'); + } + return correlation as PaymentIntentCorrelation; +} + +export type PaymentIntentLedgerStatus = + | 'pending' + | 'authorized' + | 'captured' + | 'failed' + | 'voided' + | 'settled' + | 'partially_refunded'; + +type PaymentDecision = { + action: 'transition' | 'repair' | 'unexpected'; + status: 'captured' | 'failed' | 'voided' | PaymentIntentLedgerStatus; +}; + +const targetStatus: Record< + Exclude, + 'captured' | 'failed' | 'voided' +> = { + succeeded: 'captured', + payment_failed: 'failed', + canceled: 'voided', + requires_action: 'failed', +}; + +/** Pure monotonic transition policy shared by every PaymentIntent webhook. */ +export function decidePaymentIntentTransition( + current: PaymentIntentLedgerStatus, + event: PaymentIntentEvent, + requestStatus?: 'pending' | 'accepted' | 'denied', +): PaymentDecision { + if (event === 'processing') { + return current === 'pending' + ? { action: 'repair', status: current } + : { action: 'unexpected', status: current }; + } + const target = targetStatus[event]; + if (event === 'succeeded' && requestStatus === 'denied' && current !== 'captured') { + throw new ConflictException( + 'A captured provider payment cannot finalize after the booking request was denied', + ); + } + if (current === target) return { action: 'repair', status: current }; + if (current === 'pending' || (current === 'authorized' && event === 'succeeded')) { + return { action: 'transition', status: target }; + } + return { action: 'unexpected', status: current }; +} + +export type RefundProviderStatus = + | 'succeeded' + | 'pending' + | 'requires_action' + | 'failed' + | 'canceled'; + +export function decideRefundTransition( + current: 'pending' | 'completed' | 'failed', + providerStatus: RefundProviderStatus, +) { + const target = providerStatus === 'succeeded' + ? 'completed' as const + : providerStatus === 'failed' || providerStatus === 'canceled' + ? 'failed' as const + : 'pending' as const; + if (current === target) { + return { + action: current === 'pending' ? 'record_pending' as const : 'repair' as const, + status: current, + }; + } + if (current === 'pending') return { action: 'transition' as const, status: target }; + return { action: 'unexpected' as const, status: current }; +} + +export type RefundCorrelation = { + claimId: string; + propertyId: string; + bookingRequestId: string; + paymentId: string; +}; + +export function refundCorrelation( + metadata: Record | null | undefined, +): RefundCorrelation { + const correlation = { + claimId: metadata?.['haip_claim_id'], + propertyId: metadata?.['haip_property_id'], + bookingRequestId: metadata?.['haip_booking_request_id'], + paymentId: metadata?.['haip_payment_id'], + }; + if (!correlation.claimId + || !correlation.propertyId + || !correlation.bookingRequestId + || !correlation.paymentId) { + throw new BadRequestException('Stripe refund is missing exact HAIP correlation metadata'); + } + return correlation as RefundCorrelation; +} 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 9aba09a3..e97f622e 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 Stripe from 'stripe'; /** @@ -43,6 +50,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; @@ -109,6 +119,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; @@ -132,6 +156,11 @@ export class StripeWebhookController { 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; @@ -143,7 +172,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', @@ -160,6 +191,11 @@ export class StripeWebhookController { const payment = await this.findPaymentByGatewayTransactionId(pi.id); 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'; @@ -170,7 +206,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', @@ -187,6 +225,11 @@ export class StripeWebhookController { const payment = await this.findPaymentByGatewayTransactionId(pi.id); if (!payment) return; + if (this.shouldDelegateToBookingRequestHandler(payment)) { + await this.bookingRequestStripeHandler!.handlePaymentIntentCanceled(pi, payment); + return; + } + if (payment.status === 'voided') return; await this.db @@ -195,7 +238,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', @@ -208,6 +253,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 @@ -218,6 +302,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)}`; @@ -310,6 +399,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..cc934639 100644 --- a/apps/api/src/modules/reservation/availability.service.ts +++ b/apps/api/src/modules/reservation/availability.service.ts @@ -1,5 +1,5 @@ -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 { DRIZZLE } from '../../database/database.module'; @@ -13,6 +13,55 @@ export interface AvailabilityResult { overbookingBuffer: number; } +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +/** Enumerate the exact canonical nights consumed by [checkIn, checkOut). */ +export function stayDates(checkIn: string, checkOut: string): string[] { + if (!ISO_DATE.test(checkIn) || !ISO_DATE.test(checkOut)) { + throw new BadRequestException('Stay dates must use YYYY-MM-DD'); + } + const start = new Date(`${checkIn}T00:00:00.000Z`); + const end = new Date(`${checkOut}T00:00:00.000Z`); + if ( + Number.isNaN(start.getTime()) + || Number.isNaN(end.getTime()) + || start.toISOString().slice(0, 10) !== checkIn + || end.toISOString().slice(0, 10) !== checkOut + || end <= start + ) { + throw new BadRequestException('Check-out must be after check-in'); + } + + const dates: string[] = []; + for (let date = new Date(start); date < end; date.setUTCDate(date.getUTCDate() + 1)) { + dates.push(date.toISOString().slice(0, 10)); + } + return dates; +} + +/** 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 +77,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 +107,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 +122,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 +181,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..0318c61d 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 '../../booking-request/booking-request-date.validator'; 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/src/modules/webhook/webhook-delivery.service.spec.ts b/apps/api/src/modules/webhook/webhook-delivery.service.spec.ts index f7016dfe..83213d21 100644 --- a/apps/api/src/modules/webhook/webhook-delivery.service.spec.ts +++ b/apps/api/src/modules/webhook/webhook-delivery.service.spec.ts @@ -51,14 +51,27 @@ function createStatefulMockDb(subscription: any) { }; }), insert: vi.fn((_tbl: any) => ({ - values: vi.fn((vals: any) => ({ - returning: vi.fn(() => { + values: vi.fn((vals: any) => { + const insertOnce = () => { + const existing = vals.logicalEventId + ? Array.from(deliveries.values()).find((row) => + row.propertyId === vals.propertyId + && row.subscriptionId === vals.subscriptionId + && row.logicalEventId === vals.logicalEventId) + : undefined; + if (existing) return Promise.resolve([]); const id = `del-${idCounter++}`; const row = { id, ...vals }; deliveries.set(id, row); return Promise.resolve([row]); - }), - })), + }; + return { + returning: vi.fn(insertOnce), + onConflictDoNothing: vi.fn(() => ({ + returning: vi.fn(insertOnce), + })), + }; + }), })), update: vi.fn((_tbl: any) => ({ set: vi.fn((vals: any) => ({ @@ -136,7 +149,7 @@ describe('WebhookDeliveryService', () => { expect(stored.attempts).toBe(0); }); - it('worker POSTs with HMAC signature + event headers', async () => { + it('uses the delivery row ID as the event header for legacy events', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200 }); const db = createStatefulMockDb(subscription); const queue = createMockQueue(); @@ -166,6 +179,124 @@ describe('WebhookDeliveryService', () => { expect(stored.attempts).toBe(1); }); + it('reuses one persisted delivery and stable header across event replay', async () => { + fetchMock + .mockResolvedValueOnce({ ok: false, status: 500 }) + .mockResolvedValueOnce({ ok: true, status: 200 }); + const db = createStatefulMockDb(subscription); + const queue = createMockQueue(); + const service = new WebhookDeliveryService( + db as unknown as ConstructorParameters[0], + undefined, + queue as unknown as ConstructorParameters[2], + ); + const logicalEventId = 'bbbbbbbb-0000-4000-a000-000000000002'; + + const first = await service.enqueue(payload, subscription.id, logicalEventId); + const replay = await service.enqueue(payload, subscription.id, logicalEventId); + + expect(replay.id).toBe(first.id); + expect(db._deliveries.size).toBe(1); + expect(db._deliveries.get(first.id).logicalEventId).toBe(logicalEventId); + expect(queue.add).toHaveBeenCalledTimes(2); + expect(queue.add.mock.calls.map((call) => call[2]?.jobId)).toEqual([ + first.id, + first.id, + ]); + + await expect( + service.processDeliveryJob({ deliveryId: first.id, propertyId: 'prop-1' }), + ).rejects.toThrow('scheduled for retry'); + await service.processDeliveryJob({ deliveryId: first.id, propertyId: 'prop-1' }); + await service.processDeliveryJob({ deliveryId: replay.id, propertyId: 'prop-1' }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls.map((call) => + call[1].headers['X-HAIP-Event-Id'])).toEqual([ + logicalEventId, + logicalEventId, + ]); + }); + + it('creates separate deliveries for different persisted logical events', async () => { + const db = createStatefulMockDb(subscription); + const queue = createMockQueue(); + const service = new WebhookDeliveryService( + db as unknown as ConstructorParameters[0], + undefined, + queue as unknown as ConstructorParameters[2], + ); + + const first = await service.enqueue( + payload, + subscription.id, + 'bbbbbbbb-0000-4000-a000-000000000002', + ); + const second = await service.enqueue( + payload, + subscription.id, + 'bbbbbbbb-0000-4000-a000-000000000003', + ); + + expect(second.id).not.toBe(first.id); + expect(db._deliveries.size).toBe(2); + expect(Array.from(db._deliveries.values()).map((row) => row.logicalEventId)).toEqual([ + 'bbbbbbbb-0000-4000-a000-000000000002', + 'bbbbbbbb-0000-4000-a000-000000000003', + ]); + }); + + it('returns the same delivery from concurrent persisted event creation', async () => { + const db = createStatefulMockDb(subscription); + const queue = createMockQueue(); + const service = new WebhookDeliveryService( + db as unknown as ConstructorParameters[0], + undefined, + queue as unknown as ConstructorParameters[2], + ); + const logicalEventId = 'bbbbbbbb-0000-4000-a000-000000000002'; + + const [first, replay] = await Promise.all([ + service.enqueue(payload, subscription.id, logicalEventId), + service.enqueue(payload, subscription.id, logicalEventId), + ]); + + expect(replay.id).toBe(first.id); + expect(db._deliveries.size).toBe(1); + expect(queue.add.mock.calls.map((call) => call[2]?.jobId)).toEqual([ + first.id, + first.id, + ]); + }); + + it('requeues the existing delivery when the first queue write is lost', async () => { + const db = createStatefulMockDb(subscription); + const queue = createMockQueue(); + queue.add + .mockRejectedValueOnce(new Error('Redis unavailable')) + .mockResolvedValueOnce({ id: 'job-recovered' }); + const service = new WebhookDeliveryService( + db as unknown as ConstructorParameters[0], + undefined, + queue as unknown as ConstructorParameters[2], + ); + const logicalEventId = 'bbbbbbbb-0000-4000-a000-000000000002'; + + await expect( + service.enqueue(payload, subscription.id, logicalEventId), + ).rejects.toThrow('Redis unavailable'); + const [stored] = Array.from(db._deliveries.values()); + + const recovered = await service.enqueue(payload, subscription.id, logicalEventId); + + expect(recovered.id).toBe(stored.id); + expect(db._deliveries.size).toBe(1); + expect(queue.add.mock.calls.map((call) => call[2]?.jobId)).toEqual([ + stored.id, + stored.id, + ]); + }); + it('updates the row and throws so BullMQ retries on non-2xx response', async () => { fetchMock.mockResolvedValue({ ok: false, status: 500 }); const db = createStatefulMockDb(subscription); diff --git a/apps/api/src/modules/webhook/webhook-delivery.service.ts b/apps/api/src/modules/webhook/webhook-delivery.service.ts index 6c93eeb9..b0be8612 100644 --- a/apps/api/src/modules/webhook/webhook-delivery.service.ts +++ b/apps/api/src/modules/webhook/webhook-delivery.service.ts @@ -56,6 +56,7 @@ interface WebhookDeliveryJob { } type DeliveryAttemptOutcome = 'delivered' | 'retry' | 'failed' | 'skipped'; +type WebhookDeliveryRow = typeof webhookDeliveries.$inferSelect; interface WebhookDeliveryQueue { add(name: string, data: WebhookDeliveryJob, options?: JobsOptions): Promise; @@ -111,22 +112,49 @@ export class WebhookDeliveryService implements OnModuleInit, OnModuleDestroy { } /** - * Enqueue deliveries for an event — one row per matching subscription, - * then add a durable BullMQ job for the worker. + * Enqueue one delivery per subscription and persisted logical event, then + * add its durable BullMQ job. Re-adding an existing row recovers a crash + * between the database insert and queue write; BullMQ deduplicates the UUID + * delivery job ID while an existing job remains present. */ - async enqueue(payload: DeliveryPayload, subscriptionId: string) { - const [delivery] = await this.db + async enqueue( + payload: DeliveryPayload, + subscriptionId: string, + logicalEventId?: string, + ) { + const [inserted] = await this.db .insert(webhookDeliveries) .values({ propertyId: payload.propertyId, subscriptionId, + logicalEventId: logicalEventId ?? null, eventType: payload.eventType, payload, status: 'pending', attempts: 0, }) + .onConflictDoNothing() .returning(); + let delivery = inserted as WebhookDeliveryRow | undefined; + if (!delivery && logicalEventId) { + const candidates = await this.db + .select() + .from(webhookDeliveries) + .where(and( + eq(webhookDeliveries.propertyId, payload.propertyId), + eq(webhookDeliveries.subscriptionId, subscriptionId), + eq(webhookDeliveries.logicalEventId, logicalEventId), + )); + delivery = candidates.find((candidate: WebhookDeliveryRow) => + candidate.propertyId === payload.propertyId + && candidate.subscriptionId === subscriptionId + && candidate.logicalEventId === logicalEventId); + } + if (!delivery) { + throw new Error('Webhook delivery could not be created or recovered'); + } + await this.enqueueDeliveryJob(delivery.id, payload.propertyId); return delivery; @@ -209,7 +237,7 @@ export class WebhookDeliveryService implements OnModuleInit, OnModuleDestroy { headers: { 'Content-Type': 'application/json', 'X-HAIP-Signature': signature, - 'X-HAIP-Event-Id': delivery.id, + 'X-HAIP-Event-Id': delivery.logicalEventId ?? delivery.id, 'X-HAIP-Event-Type': delivery.eventType, }, body, diff --git a/apps/api/src/modules/webhook/webhook.service.spec.ts b/apps/api/src/modules/webhook/webhook.service.spec.ts new file mode 100644 index 00000000..c6a50025 --- /dev/null +++ b/apps/api/src/modules/webhook/webhook.service.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WebhookService, type WebhookPayload } from './webhook.service'; + +describe('WebhookService persisted dispatch', () => { + it('adds the stable logical event ID only to the internal dispatch envelope', async () => { + const db = { insert: vi.fn() }; + const eventEmitter = { emitAsync: vi.fn().mockResolvedValue([]) }; + const service = new WebhookService( + db as unknown as ConstructorParameters[0], + eventEmitter as unknown as ConstructorParameters[1], + ); + const payload: WebhookPayload = { + event: 'booking_request.created', + entityType: 'booking_request', + entityId: 'bbbbbbbb-0000-4000-a000-000000000001', + propertyId: 'aaaaaaaa-0000-4000-a000-000000000001', + data: { + requestId: 'bbbbbbbb-0000-4000-a000-000000000001', + status: 'pending', + }, + timestamp: '2026-08-24T17:15:00.000Z', + }; + + await service.dispatchPersisted( + payload, + 'bbbbbbbb-0000-4000-a000-000000000002', + ); + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'booking_request.created', + { + ...payload, + logicalEventId: 'bbbbbbbb-0000-4000-a000-000000000002', + }, + ); + expect(payload).not.toHaveProperty('logicalEventId'); + expect(db.insert).not.toHaveBeenCalled(); + }); + + it('rejects a persisted dispatch name outside the shared WebhookEvent catalog', async () => { + const eventEmitter = { emitAsync: vi.fn().mockResolvedValue([]) }; + const service = new WebhookService( + { insert: vi.fn() } as unknown as ConstructorParameters[0], + eventEmitter as unknown as ConstructorParameters[1], + ); + const payload = { + event: 'payment.retained', + entityType: 'booking_request_payment_resolution', + entityId: 'bbbbbbbb-0000-4000-a000-000000000001', + data: {}, + timestamp: '2026-08-25T00:00:00.000Z', + } as unknown as WebhookPayload; + + await expect(service.dispatchPersisted(payload, 'logical-event-1')) + .rejects.toThrow(/unknown persisted webhook event/i); + expect(eventEmitter.emitAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/webhook/webhook.service.ts b/apps/api/src/modules/webhook/webhook.service.ts index 07c18d35..cfda8f32 100644 --- a/apps/api/src/modules/webhook/webhook.service.ts +++ b/apps/api/src/modules/webhook/webhook.service.ts @@ -1,16 +1,17 @@ import { Injectable, Inject } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { auditLogs } from '@telivityhaip/database'; -import { type WebhookEvent } from '@telivityhaip/shared'; +import { WEBHOOK_EVENTS, type WebhookEvent } from '@telivityhaip/shared'; import { DRIZZLE } from '../../database/database.module'; export interface WebhookPayload { - event: string; + event: WebhookEvent; entityType: string; entityId: string; propertyId?: string; data: Record; timestamp: string; + logicalEventId?: string; } @Injectable() @@ -20,6 +21,24 @@ export class WebhookService { private readonly eventEmitter: EventEmitter2, ) {} + /** + * Dispatch a payload whose audit/outbox records were already committed by + * the domain transaction. Async listeners are awaited so a durable caller + * can retain and retry its pending consequence on delivery failure. + */ + async dispatchPersisted( + payload: WebhookPayload, + logicalEventId: string, + ): Promise { + if (!Object.hasOwn(WEBHOOK_EVENTS, payload.event)) { + throw new Error(`Unknown persisted webhook event: ${String(payload.event)}`); + } + await this.eventEmitter.emitAsync(payload.event, { + ...payload, + logicalEventId, + } satisfies WebhookPayload); + } + /** * Emit a webhook event and log it to the audit trail. */ 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 ( + +