From d71dfe7205f8a4aaca540e84ed26f5a322a0b854 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 15:01:29 +0200 Subject: [PATCH 01/87] docs: design request-first direct bookings --- CONTEXT.md | 44 ++ .../2026-08-24-booking-requests-design.md | 510 ++++++++++++++++++ 2 files changed, 554 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/superpowers/specs/2026-08-24-booking-requests-design.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..8401c7ca --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,44 @@ +# Domain glossary + +## Booking Request + +A guest's request for the property to review a sellable stay before deciding +whether to create a reservation. A Booking Request does not reserve inventory. + +## Request Mode + +A direct-booking mode in which submitting the guest form creates a Booking +Request instead of a reservation. + +## Waitlist Entry + +Non-deducting demand recorded when the requested stay is not currently +available. A Waitlist Entry is not a Booking Request. + +## Quote Snapshot + +The immutable record of the offer shown when a Booking Request was submitted. +Later prices and accepted prices do not rewrite this record. + +## Accepted Price + +The stay price chosen by staff when accepting a Booking Request. It can match +the Quote Snapshot, the current authoritative quote, or a justified custom +price. + +## Payment Plan + +A staff-managed set of expected partial payments. A Payment Plan can express +amounts or percentages and due milestones, but never initiates a payment by +itself. + +## Payment Movement + +An attempted or completed movement of money, either through the configured +card gateway or recorded after taking place outside HAIP. + +## Stay Amendment + +An audited change to an accepted reservation, such as extending its departure +date. A Stay Amendment does not rewrite the original Booking Request or its +Accepted Price. diff --git a/docs/superpowers/specs/2026-08-24-booking-requests-design.md b/docs/superpowers/specs/2026-08-24-booking-requests-design.md new file mode 100644 index 00000000..d3f8bb64 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-booking-requests-design.md @@ -0,0 +1,510 @@ +# Request-first direct bookings — design + +**Status:** Approved in design review +**Issue:** https://github.com/TelivityAI/haip/issues/332 +**Date:** 2026-08-24 + +## Summary + +HAIP will support an opt-in request-first direct-booking flow. A guest selects +a sellable stay, answers property-configured questions, optionally or +mandatorily saves a card through Stripe, and submits a Booking Request. Staff +can review the request, take or record partial payments, and accept or deny it. +Acceptance creates the reservation regardless of payment state. + +Booking Requests are a separate aggregate from waitlist. They apply only to +currently sellable inventory; unavailable demand remains a waitlist concern. + +The feature is delivered as an end-to-end workflow: persistence, public +submission, staff API, dashboard, booking widget, payment handling, email, +audit, and tests. Request mode stays disabled by default, preserving the +existing instant-booking behavior. + +## Goals + +- Let a property choose `instant` or `request` direct-booking mode. +- Keep submission non-deducting and reservation-free. +- Let staff accept or deny requests and create exactly one reservation on + acceptance. +- Collect a reusable card securely without charging at submission. +- Support multiple staff-initiated partial payments. +- Support both gateway charges and externally collected payments. +- Keep acceptance and payment independent. +- Keep the original offer, later quotes, decisions, money, and amendments + auditable. +- Reuse reservation, folio, payment, email, permission, webhook, and audit + behavior already present in HAIP. + +## Non-goals + +- No automatic charges. +- No request expiration. +- No request submission when inventory is unavailable. +- No public request status page or public management token. +- No guest withdrawal, reservation cancellation, or stay modification. +- No payment-authentication recovery link. A card charge requiring additional + guest authentication is recorded as failed. +- No changes to waitlist semantics. + +## Maintainer decisions + +The issue maintainers confirmed: + +- `booking_requests` is separate from waitlist. +- Zero availability routes to waitlist, not request mode. +- `bookingMode: instant | request` belongs to `booking_engine_config`. +- Queue reads use `reservations.read`; decisions and payment operations use + `reservations.write`; configuration uses `bookingengine.manage`. + +The original RFC proposed public follow-up access. Product design subsequently +removed it. The issue must be updated before implementation so maintainers can +see that the end-to-end scope contains no public status token or guest page. + +Maintainers also promised knowledge-base references for the option/request +lifecycle and quote-snapshot retention. Implementation must use those sources +when supplied. Until then, this feature does not add automated snapshot +deletion or invent a retention period. + +## Aggregate and lifecycle + +### Booking Request + +A Booking Request has the public lifecycle: + +```text +pending ──accept──> accepted + │ + └──deny───────> denied +``` + +- Submission always creates `pending`. +- `accepted` and `denied` are terminal request decisions. +- Acceptance creates and links one reservation. +- Denial creates no reservation. +- Requests never expire. +- Payment status is not part of the decision state machine. +- Internal processing markers may support recovery, but must not introduce + additional product-visible lifecycle states. + +### Denial with money + +A request with captured money cannot be denied silently. Staff must resolve +each positive movement by one of these explicit actions: + +- refund a gateway payment; +- record that an external payment was returned; +- retain the money with a mandatory reason. + +The denial action remains unavailable until every captured amount has a +resolution. Refunds and retained amounts remain in the financial and audit +history. + +## Persistence + +### `booking_requests` + +The aggregate stores: + +- `id`, `propertyId`, and `status`; +- arrival, departure, occupancy, requested room type, and rate plan; +- guest name, email, phone, special requests, and structured application + answers; +- immutable quote snapshot, including currency, line items, taxes, services, + policies, and total shown at submission; +- snapshot of the form questions displayed at submission; +- card-collection result: Stripe customer and payment-method references, + brand, last four, consent text/version, consent timestamp, and collection + status; never raw card data; +- authoritative quote captured during acceptance; +- accepted price source: `submitted | current | custom`; +- accepted total, custom-price reason, decision actor, and decision timestamps; +- linked reservation and folio after acceptance; +- denial reason and retained-payment rationale where applicable; +- creation and update timestamps. + +All reads and writes are scoped by both entity ID and caller-supplied +`propertyId`. + +### Configurable questions + +Question definitions live in Booking Engine Settings at property scope. The +supported types are: + +- short text; +- long text; +- single select; +- multiple select; +- yes/no; +- date. + +Each definition has a stable ID, label, type, ordered options where applicable, +display order, active state, and required state. Submission validates answers +against the current active definition. The request stores the displayed +definition and answers as an immutable snapshot, so later configuration changes +do not rewrite old applications. + +### Payment plan + +`booking_request_installments` stores staff-managed expected payments. An +installment contains: + +- property and request scope; +- label and display order; +- either a fixed amount or percentage; +- resolved amount where a percentage has been applied; +- due milestone: explicit date, arrival, checkout, or manual; +- amount allocated from captured movements; +- status derived from allocation, such as unpaid, partial, or paid. + +Installments are planning records. Reaching a date or milestone never initiates +a charge. Staff can add, edit, reorder, or remove only the unallocated portion. +Several movements can satisfy one installment, and one movement may be +allocated across installments when needed. + +### Payment movements + +The existing `payments` ledger is extended with request provenance. Before +acceptance, a payment targets the Booking Request. After acceptance, the same +row is also linked to the reservation folio without duplication while retaining +its request provenance. + +The ledger must distinguish: + +- Stripe charge attempts and results; +- externally collected payments with method, date, provider/reference, and + notes; +- full and partial refunds; +- recorded external returns; +- retained amounts with reason; +- installment allocations. + +The persistence layer enforces a valid financial target: folio, house account, +or Booking Request. Existing house-account and folio behavior remains +backward-compatible. + +## Booking Engine Settings + +`booking_engine_config` gains: + +- `bookingMode: instant | request`, default `instant`; +- `paymentMethodCollection: required | optional | disabled`, default + `disabled` for backward-compatible migration; +- property-scoped form-question definitions. + +The existing `autoConfirm` field keeps its current instant-booking meaning and +does not control Booking Request acceptance. + +The public configuration response exposes only the information needed to +render the widget: booking mode, card-collection policy, form schema, branding, +and existing sellable inventory configuration. It never exposes gateway secret +credentials. + +## Public widget + +Request mode uses a guided three-step flow: + +1. **Stay:** dates, occupancy, room type, and rate plan. +2. **Application:** core guest details plus active configurable questions. +3. **Payment details:** Stripe Payment Element, skipped when disabled and + explicitly skippable when optional. + +The payment step states clearly that submission saves a payment method but +does not charge or confirm a reservation. Consent is explicit and versioned. + +### Card-collection policies + +- `required`: successful SetupIntent confirmation and consent are required + before submission. +- `optional`: the guest explicitly chooses to add a card or continue without + one. +- `disabled`: Stripe is not loaded and the flow proceeds from application to + final confirmation. + +### Submission + +Submission uses a dedicated public request endpoint protected by the existing +publishable booking key. It cannot enumerate requests. + +The server: + +1. validates property mode and input; +2. validates the application against the current form schema; +3. enforces the card-collection policy; +4. rechecks sellability and availability; +5. computes the authoritative submission quote; +6. stores the request and immutable snapshots; +7. queues the receipt email; +8. returns an acknowledgement identifier and message. + +The identifier is not a bearer credential and cannot be used to read or modify +the request. + +## Staff interface + +### API + +Staff endpoints provide: + +- paginated request queue filtered by property, state, stay dates, guest, and + card presence; +- property-scoped request detail; +- acceptance and denial; +- installment creation, editing, ordering, and deletion; +- explicit Stripe charge with positive amount and idempotency key; +- external payment recording with positive amount, method, processed date, + and reference; +- partial or full refund; +- external-return recording; +- retained-payment resolution with mandatory reason; +- email delivery history and manual retry. + +There are no public read, update, withdrawal, or cancellation endpoints. + +### Dashboard + +The dashboard adds **Booking Requests** under Front Desk. + +The queue shows status, stay dates, guest, requested total, and card presence. +Each request opens a dedicated detail page with persistent Accept and Deny +actions and these tabs: + +- **Overview:** stay, application, quote comparison, card summary, and decision. +- **Payments & plan:** totals, installments, movements, refunds, and payment + actions. +- **Messages:** receipt, acceptance, denial, payment, refund, and failure email + deliveries with retry. +- **Audit:** immutable business-action history. + +The acceptance modal compares submitted and current quotes and lets staff +choose submitted, current, or custom total. A custom total requires a reason. + +The denial action is disabled until captured money has an explicit resolution. + +## Acceptance + +Acceptance and payment are independent. Staff may charge before or after the +decision, and a request can be accepted with no payment. + +Acceptance: + +1. acquires exclusive processing ownership for the pending request; +2. rechecks property scope, current availability, and rate-plan sellability; +3. produces a current authoritative quote; +4. validates the selected submitted, current, or custom total; +5. creates the guest, reservation, booking, folio, and ancillary links through + existing canonical behavior; +6. links existing request payments to the folio without duplicating them; +7. records the decision and final price; +8. queues the acceptance email and emits audit/webhook events. + +A unique request-to-reservation relationship makes retry safe. If a crash +occurs after reservation creation, the next attempt recovers and returns that +reservation rather than creating another. Accepting an already accepted +request returns the linked reservation. Lack of availability leaves the +request pending and changes no business state. + +## Payments + +### Stripe charge + +The dashboard charge action requires a positive amount and explicit staff +confirmation. HAIP writes a pending attempt before calling Stripe and uses a +stable idempotency key. It then records captured or failed status. + +No charge is automatic. A charge requiring additional guest authentication is +recorded as failed; HAIP does not send a recovery link. + +### External payment + +Staff can record cash, bank transfer, offline terminal, or another supported +method. The record includes amount, currency, processed time, method, +reference, and notes. A unique external reference or client idempotency key +prevents duplicate submission. + +### Partial payments + +Several movements can be taken at any time and allocated to installments. +Examples include 30% before arrival and 70% at arrival or checkout. Milestones +are informational; staff always initiates the movement. + +An amount of zero or less is rejected. Refund totals cannot exceed the net +captured amount. + +## After acceptance: folio and stay changes + +The Booking Request remains immutable evidence of the submitted and accepted +deal. The reservation and folio become the active operational and financial +records. + +The request detail continues to show the linked folio: + +- accepted accommodation price; +- subsequent folio charges such as restaurant, minibar, spa, or other extras; +- captured and returned amounts; +- current balance due. + +Staff may update the payment plan or take any partial amount against the +current balance. No automatic reconciliation initiates money movement. + +### Stay extension or amendment + +From an accepted request, `Modify stay` delegates to the linked reservation. +For a date extension, HAIP: + +1. checks availability for the amended complete stay; +2. recalculates the authoritative stay quote; +3. compares the prior accepted price with the current quote; +4. lets staff choose the prior-rate basis, current quote, or a custom total; +5. requires a reason for custom pricing; +6. updates the reservation only if availability remains valid; +7. leaves the original request and Accepted Price unchanged; +8. records prior/new dates, prices, reason, and actor; +9. exposes the revised reservation and folio balance in Payments & plan. + +Existing folio charges continue to determine the operational balance. The +amendment must not double-post room revenue already handled by existing folio +or night-audit behavior. + +## Email + +Automatic transactional emails cover: + +- request received; +- request accepted; +- request denied; +- payment captured or externally recorded; +- refund or external return recorded; +- payment failed. + +Delivery has its own `pending | sent | failed` record. Email is a consequence, +not part of the transaction that changes the request or moves money. Failure +does not roll back a decision or payment. Staff can retry a failed delivery. + +Emails contain no private request-management link and no payment-authentication +link. + +## Permissions and tenant isolation + +- Queue and detail: `reservations.read`. +- Accept, deny, installments, payment, refund, and email retry: + `reservations.write`. +- Booking mode, card policy, and form configuration: + `bookingengine.manage`. + +Every request, installment, payment, delivery, and mutation carries and filters +by caller-supplied `propertyId`. Request IDs, payment IDs, reservation IDs, and +form-definition IDs are never used alone for a property-scoped operation. + +Public submission derives property scope only from the validated publishable +booking credential, consistent with existing booking-engine endpoints. + +## Audit and webhooks + +Audit entries cover submission, decision attempts and results, quote choice, +custom pricing, installment changes, payment attempts/results, external +payments, refunds, retained money, email delivery, and stay amendments. + +Webhook events follow the existing `entity.action` convention. At minimum the +design needs request created/accepted/denied, payment received/failed/refunded, +and reservation modified events. Payloads include property scope and stable +entity identifiers but exclude application answers, consent text, and payment +tokens unless an existing security-reviewed event contract explicitly permits +them. + +## Consistency and recovery + +- Request acceptance is concurrency-safe and produces at most one reservation. +- Payment attempts are persisted before external gateway calls. +- Gateway calls occur outside database transactions and use stable idempotency + keys. +- Retries return or complete prior work instead of repeating side effects. +- External payment recording is idempotent. +- Property scope is checked before any external side effect. +- Email and webhook failures are recorded and retryable without rolling back + committed business state. +- No database transaction remains open while waiting on an external provider. + +## Error behavior + +- Unavailable stay at submission or acceptance: conflict; no request/reservation + mutation for acceptance, and no request created at submission. +- Invalid or inactive offer: validation failure with no mutation. +- Required card missing or setup incomplete: submission rejected. +- Duplicate acceptance: return the linked reservation. +- Duplicate charge idempotency key: return the previous attempt/result. +- Additional card authentication required: failed payment, no link generated. +- Denial with unresolved money: conflict listing the movements to resolve. +- Email failure: business action succeeds and delivery shows failed/retryable. +- Cross-property identifier: not found under the caller's property scope. + +## Test strategy + +### Domain and persistence + +- migration defaults and rollback-safe forward schema; +- request state transitions and terminal-state behavior; +- immutable quote and form snapshots; +- installment calculation and allocation; +- financial target and refund constraints; +- request-to-reservation uniqueness. + +### API and security + +- booking mode and card policy combinations; +- public submission cannot enumerate or retrieve requests; +- property isolation on every route and nested resource; +- confirmed permissions for reads, decisions, configuration, and payments; +- configurable-question validation and historical snapshots; +- no raw card data stored or logged. + +### Concurrency and failure + +- simultaneous acceptance creates one reservation; +- simultaneous/retried charge creates one gateway operation; +- partial and repeated refunds cannot exceed net captured amount; +- failure after external success can be reconciled on retry/webhook; +- email and webhook failure do not roll back business state. + +### Product flow + +- instant mode remains unchanged; +- request widget steps and required/optional/disabled card modes; +- queue and detail tabs; +- accept with submitted/current/custom price; +- deny with no money and with each money-resolution path; +- multiple partial gateway and external payments; +- linked folio balance after additional hotel charges; +- stay extension with availability, repricing, audit, and no duplicate revenue; +- transactional email creation and retry. + +### End-to-end + +At least one end-to-end scenario covers: + +1. configure request mode and custom questions; +2. submit with a saved card; +3. create a 30/70 payment plan; +4. take a partial gateway payment; +5. accept with a recalculated price; +6. add an external payment; +7. add a folio extra; +8. extend the stay; +9. inspect the resulting reservation, folio, audit, and messages. + +## Rollout and compatibility + +- Existing properties migrate to `bookingMode=instant` and + `paymentMethodCollection=disabled`. +- Instant booking endpoints and widget responses remain compatible. +- Request-only routes and UI remain unreachable unless request mode is enabled. +- Configuration can return from request to instant mode without deleting + existing requests; staff retains access to their history. +- Deployment must not expose request mode until schema, API, dashboard, widget, + payment, and email pieces are all present. + +## Delivery scope + +The product slice is complete only when the end-to-end workflow above is +usable. Implementation may use reviewable commits or stacked pull requests, +but partial infrastructure must remain disabled and backward-compatible until +the complete feature lands. From 0a6cdfffbef42803dd6dc7a830d6cecc8b14e6a7 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 15:11:50 +0200 Subject: [PATCH 02/87] docs: plan booking request implementation --- .../plans/2026-08-24-booking-requests.md | 956 ++++++++++++++++++ 1 file changed, 956 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-24-booking-requests.md diff --git a/docs/superpowers/plans/2026-08-24-booking-requests.md b/docs/superpowers/plans/2026-08-24-booking-requests.md new file mode 100644 index 00000000..65afe766 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-booking-requests.md @@ -0,0 +1,956 @@ +# Request-first Direct Bookings Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver an opt-in, end-to-end request-first booking flow with configurable applications, saved cards, manual partial payments, staff decisions, linked folios, email, dashboard, widget, and audited stay amendments. + +**Architecture:** A new `BookingRequestModule` owns the request aggregate and exposes separate public-submission and staff-management interfaces. Existing booking-engine, reservation, payment, folio, email, audit, and webhook modules are extended through explicit seams; instant booking remains unchanged and request mode remains unreachable until the vertical slice is complete. + +**Tech Stack:** TypeScript strict mode, NestJS, Drizzle ORM, PostgreSQL, Stripe SDK/Elements, React, TanStack Query, React Router, Vitest, Testing Library, pnpm workspaces. + +**Spec:** `docs/superpowers/specs/2026-08-24-booking-requests-design.md` + +## Global Constraints + +- Before Task 2, read the maintainer reply on issue #332 and the promised KB references. If either contradicts the spec, update and re-approve the spec before continuing. +- Never store raw card data; submission sends only a SetupIntent identifier and the server resolves trusted Stripe references. +- Every property-scoped read, update, and delete filters by both entity ID and caller-supplied `propertyId`. +- Public property scope comes only from the validated publishable booking credential. +- No automatic charges, request expiration, public request management, guest withdrawal, or authentication-recovery link. +- Acceptance creates a reservation independently of payment and is idempotent. +- Request mode defaults to `instant`; card collection defaults to `disabled`. +- Do not add runtime dependencies unless an existing package cannot satisfy an approved requirement. +- Business logic is test-first; every task ends with focused tests and a commit. + +## File and module map + +- `packages/database/src/schema/booking-request.ts`: request aggregate, installments, allocations, payment resolutions, and email deliveries. +- `packages/database/src/schema/booking-engine.ts`: request-mode, card-policy, and form-definition configuration. +- `packages/database/src/schema/folio.ts`: optional request provenance and idempotency on payment rows. +- `packages/database/src/migrations/0021_booking_requests.sql`: forward database migration and constraints. +- `apps/api/src/modules/booking-request/booking-request-state.ts`: pure request transitions and money-resolution rules. +- `apps/api/src/modules/booking-request/booking-request.service.ts`: submission, list/detail, acceptance, and denial orchestration. +- `apps/api/src/modules/booking-request/booking-request-payment.service.ts`: installments, charges, external payments, refunds, allocations, and denial resolution. +- `apps/api/src/modules/booking-request/booking-request-mailer.service.ts`: persistent transactional-email delivery and retry. +- `apps/api/src/modules/booking-request/booking-request.controller.ts`: staff API. +- `apps/api/src/modules/booking-request/booking-request-public.controller.ts`: publishable-key submission/setup API only. +- `apps/api/src/modules/payment/interfaces/saved-payment-method-gateway.interface.ts`: save-card and off-session-charge seam. +- `apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts`: Stripe implementation. +- `apps/api/src/modules/payment/mock-saved-payment-method.gateway.ts`: deterministic test/demo implementation. +- `apps/booking/src/pages/RequestApplication.tsx`: guest details plus configured questions. +- `apps/booking/src/pages/RequestPayment.tsx`: required/optional/disabled SetupIntent flow. +- `apps/booking/src/pages/RequestReceived.tsx`: acknowledgement without management credentials. +- `apps/dashboard/src/pages/BookingRequests.tsx`: queue and routed request detail. +- `apps/dashboard/src/components/booking-requests/*`: overview, payments, messages, audit, decisions, and stay amendment UI. +- `apps/dashboard/src/components/admin/BookingEngineSettings.tsx`: request-mode, card-policy, and question-builder settings. + +--- + +### Task 1: Persist the aggregate and backward-compatible configuration + +**Files:** +- Create: `packages/database/src/schema/booking-request.ts` +- Create: `packages/database/src/migrations/0021_booking_requests.sql` +- Create: `packages/database/src/booking-request-schema.spec.ts` +- Modify: `packages/database/src/schema/booking-engine.ts` +- Modify: `packages/database/src/schema/folio.ts` +- Modify: `packages/database/src/schema/index.ts` +- Modify: `packages/database/src/index.ts` +- Modify: `packages/database/src/push-schema.ts` + +**Interfaces:** +- Produces: `BookingMode`, `PaymentMethodCollection`, `BookingFormQuestion`, `bookingRequests`, `bookingRequestInstallments`, `bookingRequestPaymentAllocations`, `bookingRequestPaymentResolutions`, and `bookingRequestEmailDeliveries`. +- Produces payment provenance fields: `payments.bookingRequestId` and `payments.idempotencyKey`. + +- [ ] **Step 1: Write a failing schema contract test** + +```ts +import { describe, expect, it } from 'vitest'; +import { + bookingEngineConfig, + bookingRequests, + bookingRequestInstallments, + payments, +} from './schema/index.js'; + +describe('booking request schema', () => { + it('exports request persistence and backward-compatible config columns', () => { + expect(bookingRequests.propertyId).toBeDefined(); + expect(bookingRequests.submittedQuoteSnapshot).toBeDefined(); + expect(bookingRequestInstallments.dueMilestone).toBeDefined(); + expect(bookingEngineConfig.bookingMode).toBeDefined(); + expect(bookingEngineConfig.paymentMethodCollection).toBeDefined(); + expect(payments.bookingRequestId).toBeDefined(); + expect(payments.idempotencyKey).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run the schema test and verify it fails** + +Run: `pnpm --filter @telivityhaip/database test -- src/booking-request-schema.spec.ts` +Expected: FAIL because the request tables and columns are not exported. + +- [ ] **Step 3: Define shared configuration and form types** + +```ts +export type BookingMode = 'instant' | 'request'; +export type PaymentMethodCollection = 'required' | 'optional' | 'disabled'; +export type BookingFormQuestionType = + | 'short_text' + | 'long_text' + | 'single_select' + | 'multi_select' + | 'yes_no' + | 'date'; + +export type BookingFormQuestion = { + id: string; + label: string; + type: BookingFormQuestionType; + options?: string[]; + order: number; + isActive: boolean; + isRequired: boolean; +}; +``` + +Add `bookingMode`, `paymentMethodCollection`, and `formQuestions` to `bookingEngineConfig`, defaulting to `instant`, `disabled`, and `[]`. + +- [ ] **Step 4: Define request persistence with explicit enums and unique constraints** + +```ts +export const bookingRequestStatusEnum = pgEnum('booking_request_status', [ + 'pending', + 'accepted', + 'denied', +]); + +export const bookingRequestPriceSourceEnum = pgEnum('booking_request_price_source', [ + 'submitted', + 'current', + 'custom', +]); + +export const bookingRequests = pgTable('booking_requests', { + id: uuid('id').primaryKey().defaultRandom(), + propertyId: uuid('property_id').notNull().references(() => properties.id), + status: bookingRequestStatusEnum('status').notNull().default('pending'), + arrivalDate: date('arrival_date').notNull(), + departureDate: date('departure_date').notNull(), + roomTypeId: uuid('room_type_id').notNull().references(() => roomTypes.id), + ratePlanId: uuid('rate_plan_id').notNull().references(() => ratePlans.id), + adults: integer('adults').notNull().default(1), + children: integer('children').notNull().default(0), + guestFirstName: varchar('guest_first_name', { length: 100 }).notNull(), + guestLastName: varchar('guest_last_name', { length: 100 }).notNull(), + guestEmail: varchar('guest_email', { length: 255 }).notNull(), + guestPhone: varchar('guest_phone', { length: 50 }), + specialRequests: text('special_requests'), + serviceIds: jsonb('service_ids').$type().notNull().default([]), + formSnapshot: jsonb('form_snapshot').$type().notNull().default([]), + applicationAnswers: jsonb('application_answers').$type>().notNull().default({}), + submittedQuoteSnapshot: jsonb('submitted_quote_snapshot').notNull(), + currentQuoteSnapshot: jsonb('current_quote_snapshot'), + currencyCode: varchar('currency_code', { length: 3 }).notNull(), + stripeCustomerId: varchar('stripe_customer_id', { length: 255 }), + stripePaymentMethodId: varchar('stripe_payment_method_id', { length: 255 }), + cardLastFour: varchar('card_last_four', { length: 4 }), + cardBrand: varchar('card_brand', { length: 20 }), + consentText: text('consent_text'), + consentVersion: varchar('consent_version', { length: 40 }), + consentedAt: timestamp('consented_at', { withTimezone: true }), + acceptedPriceSource: bookingRequestPriceSourceEnum('accepted_price_source'), + acceptedTotal: numeric('accepted_total', { precision: 12, scale: 2 }), + customPriceReason: text('custom_price_reason'), + acceptedReservationId: uuid('accepted_reservation_id').references(() => reservations.id), + acceptedFolioId: uuid('accepted_folio_id').references(() => folios.id), + decidedBy: uuid('decided_by'), + decidedAt: timestamp('decided_at', { withTimezone: true }), + denialReason: text('denial_reason'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + acceptedReservationUnique: uniqueIndex('booking_requests_accepted_reservation_unique') + .on(table.acceptedReservationId), +})); +``` + +Define installment, allocation, payment-resolution, and email-delivery tables with `propertyId` on every row. Add a unique `(propertyId, idempotencyKey)` index for payments and a database check that a payment has a folio, house account, or Booking Request target. + +- [ ] **Step 5: Write migration `0021_booking_requests.sql`** + +The migration must create the enums/tables/indexes/FKs, add the three booking-engine columns and two payment columns, backfill existing config rows to `instant`/`disabled`/`[]`, and mark the new config columns `NOT NULL` only after backfill. + +- [ ] **Step 6: Apply the migration to the test database and run the schema test** + +Run: `DATABASE_URL=postgresql://haip:haip@localhost:5432/haip_test pnpm db:migrate` +Run: `pnpm --filter @telivityhaip/database test -- src/booking-request-schema.spec.ts` +Expected: migration succeeds and the test passes. + +- [ ] **Step 7: Commit** + +```bash +git add packages/database/src +git commit -m "feat(database): add booking request persistence" +``` + +--- + +### Task 2: Implement the pure request and installment domain model + +**Files:** +- Create: `apps/api/src/modules/booking-request/booking-request-state.ts` +- Create: `apps/api/src/modules/booking-request/booking-request-state.spec.ts` +- Create: `apps/api/src/modules/booking-request/booking-request-money.ts` +- Create: `apps/api/src/modules/booking-request/booking-request-money.spec.ts` + +**Interfaces:** +- Produces: `assertBookingRequestTransition(from, to)`. +- Produces: `resolveAcceptedTotal(input): { source; total; customReason }`. +- Produces: `resolveInstallmentAmount(input): Decimal`. +- Produces: `assertDenialMoneyResolved(movements, resolutions): void`. + +- [ ] **Step 1: Write failing transition and pricing tests** + +```ts +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('requires a reason for a custom accepted price', () => { + expect(() => resolveAcceptedTotal({ + source: 'custom', + submittedTotal: '1000.00', + currentTotal: '1100.00', + customTotal: '1050.00', + })).toThrow(/reason/); +}); +``` + +- [ ] **Step 2: Run tests and verify they fail** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request-state.spec.ts src/modules/booking-request/booking-request-money.spec.ts` +Expected: FAIL because the pure functions do not exist. + +- [ ] **Step 3: Implement explicit state and money functions** + +```ts +export type BookingRequestStatus = 'pending' | 'accepted' | 'denied'; + +export function assertBookingRequestTransition( + from: BookingRequestStatus, + to: Exclude, +): void { + if (from !== 'pending') { + throw new ConflictException(`Cannot transition booking request from '${from}' to '${to}'`); + } +} +``` + +Use `Decimal` for every amount and percentage. Reject non-positive custom totals, fixed installments, allocations, payments, and refunds. `assertDenialMoneyResolved` compares each captured movement's net amount with returned plus retained resolutions. + +- [ ] **Step 4: Add edge-case tests** + +Cover percentage rounding to currency precision, allocations that exceed movement/installment amounts, refund totals over net capture, zero amounts, and unresolved partial denial balances. + +- [ ] **Step 5: Run focused tests** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request-state.spec.ts src/modules/booking-request/booking-request-money.spec.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/modules/booking-request/booking-request-state.ts apps/api/src/modules/booking-request/booking-request-state.spec.ts apps/api/src/modules/booking-request/booking-request-money.ts apps/api/src/modules/booking-request/booking-request-money.spec.ts +git commit -m "feat(booking-requests): define lifecycle and money rules" +``` + +--- + +### Task 3: Extend Booking Engine Settings and validate custom questions + +**Files:** +- Create: `apps/api/src/modules/booking-engine/booking-form-questions.ts` +- Create: `apps/api/src/modules/booking-engine/booking-form-questions.spec.ts` +- Modify: `apps/api/src/modules/booking-engine/dto/be-admin.dto.ts` +- Modify: `apps/api/src/modules/booking-engine/booking-engine-config.service.ts` +- Modify: `apps/api/src/modules/booking-engine/booking-engine.service.spec.ts` + +**Interfaces:** +- Produces: `validateQuestionDefinitions(questions): BookingFormQuestion[]`. +- Produces: `validateApplicationAnswers(questions, answers): Record`. +- Extends `UpdateConfigInput` and public config with `bookingMode`, `paymentMethodCollection`, and active ordered questions. + +- [ ] **Step 1: Write failing validation tests** + +```ts +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(); +}); + +it('rejects a missing required answer', () => { + expect(() => validateApplicationAnswers([ + { id: 'arrival', label: 'Arrival time', type: 'short_text', order: 0, isActive: true, isRequired: true }, + ], {})).toThrow(/Arrival time/); +}); +``` + +- [ ] **Step 2: Run and verify failure** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-engine/booking-form-questions.spec.ts` +Expected: FAIL because validation is missing. + +- [ ] **Step 3: Add nested DTO validation and pure value validation** + +Create `BookingFormQuestionDto` with `@IsIn`, `@IsUUID`, `@MaxLength`, `@IsArray`, `@ArrayMaxSize`, and nested validation. Enforce unique IDs, unique normalized options, maximum 50 questions, and type-correct answers. + +- [ ] **Step 4: Extend config read/update behavior** + +Admin config returns all definitions. Public config returns only active questions sorted by `order`, plus booking/card modes. Reject `bookingMode=request` with `paymentMethodCollection=required` when no publishable card key is configured. + +- [ ] **Step 5: Run tests** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-engine/booking-form-questions.spec.ts src/modules/booking-engine/booking-engine.service.spec.ts` +Expected: PASS, including unchanged instant-mode expectations. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/modules/booking-engine +git commit -m "feat(booking-engine): configure request forms" +``` + +--- + +### Task 4: Add the saved-payment-method gateway seam + +**Files:** +- Create: `apps/api/src/modules/payment/interfaces/saved-payment-method-gateway.interface.ts` +- Create: `apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts` +- Create: `apps/api/src/modules/payment/stripe-saved-payment-method.gateway.spec.ts` +- Create: `apps/api/src/modules/payment/mock-saved-payment-method.gateway.ts` +- Create: `apps/api/src/modules/payment/mock-saved-payment-method.gateway.spec.ts` +- Modify: `apps/api/src/modules/payment/payment.module.ts` + +**Interfaces:** +- Produces `SAVED_PAYMENT_METHOD_GATEWAY` implementing the following interface. + +```ts +export type SavedPaymentMethod = { + setupIntentId: string; + customerId: string; + paymentMethodId: string; + cardLastFour: string; + cardBrand: string; +}; + +export interface SavedPaymentMethodGateway { + createSetup(email: string, idempotencyKey: string): Promise<{ + setupIntentId: string; + clientSecret: string; + customerId: string; + }>; + resolveSetup(setupIntentId: string): Promise; + charge(input: { + customerId: string; + paymentMethodId: string; + amount: string; + currencyCode: string; + idempotencyKey: string; + }): Promise<{ success: boolean; transactionId: string; requiresAction: boolean; errorMessage?: string }>; +} +``` + +- [ ] **Step 1: Write failing adapter tests** + +Test SetupIntent creation with `usage: 'off_session'`, resolution only when status is `succeeded`, trusted retrieval of card metadata, automatic-capture off-session PaymentIntent creation, idempotency propagation, and `requires_action` mapping to failure. + +- [ ] **Step 2: Run and verify failure** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/payment/stripe-saved-payment-method.gateway.spec.ts src/modules/payment/mock-saved-payment-method.gateway.spec.ts` +Expected: FAIL because adapters are missing. + +- [ ] **Step 3: Implement Stripe and mock adapters** + +The Stripe adapter creates a Customer and SetupIntent, retrieves the successful SetupIntent and expanded PaymentMethod, and later creates a confirmed off-session PaymentIntent with automatic capture. It returns failure instead of a client secret when later authentication is required. + +- [ ] **Step 4: Register the seam** + +In mock mode inject `MockSavedPaymentMethodGateway`; in Stripe mode inject `StripeSavedPaymentMethodGateway`. Export only the symbol/interface from `PaymentModule`. + +- [ ] **Step 5: Run focused tests and typecheck** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/payment/stripe-saved-payment-method.gateway.spec.ts src/modules/payment/mock-saved-payment-method.gateway.spec.ts` +Run: `pnpm --filter @telivityhaip/api typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/modules/payment +git commit -m "feat(payments): save and charge request payment methods" +``` + +--- + +### Task 5: Create public request setup and submission + +**Files:** +- Create: `apps/api/src/modules/booking-request/dto/submit-booking-request.dto.ts` +- Create: `apps/api/src/modules/booking-request/dto/create-request-card-setup.dto.ts` +- Create: `apps/api/src/modules/booking-request/booking-request-public.controller.ts` +- Create: `apps/api/src/modules/booking-request/booking-request.service.ts` +- Create: `apps/api/src/modules/booking-request/booking-request-submission.spec.ts` +- Create: `apps/api/src/modules/booking-request/booking-request.module.ts` +- Modify: `apps/api/src/modules/booking-engine/booking-engine.module.ts` +- Modify: `apps/api/src/modules/booking-engine/booking-engine.service.ts` +- Modify: `apps/api/src/modules/booking-engine/booking-engine.service.spec.ts` +- Modify: `apps/api/src/app.module.ts` + +**Interfaces:** +- Produces `BookingRequestService.submit(propertyId, dto): Promise`. +- Produces public routes `POST /booking-engine/request-payment-method-setup` and `POST /booking-engine/requests`. + +```ts +export type BookingRequestAcknowledgement = { + requestId: string; + status: 'pending'; + message: string; +}; +``` + +- [ ] **Step 1: Write failing submission tests** + +Cover request submission rejection in instant mode, card setup rejection when request mode/card collection is unavailable, zero availability, stale/invalid rate plan, required/optional/disabled card policies, form-answer validation, authoritative quote snapshot, trusted SetupIntent resolution, and absence of guest/reservation/folio writes. Add a regression proving `BookingEngineService.book` rejects when the property's mode is `request`, so callers cannot bypass review by invoking the old instant endpoint directly. + +- [ ] **Step 2: Run and verify failure** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request-submission.spec.ts` +Expected: FAIL because the module and service do not exist. + +- [ ] **Step 3: Implement public DTOs and controller** + +```ts +@ApiTags('Booking Engine — Booking Requests') +@Controller('booking-engine') +@Public() +@UseGuards(BookingKeyGuard, BookingEngineScopeGuard) +export class BookingRequestPublicController { + @Post('request-payment-method-setup') + createSetup(@Body() dto: CreateRequestCardSetupDto, @Req() req: any) { + return this.service.createPaymentMethodSetup(req.bookingEngine.propertyId, dto); + } + + @Post('requests') + @UseGuards(BookingThrottleGuard) + submit(@Body() dto: SubmitBookingRequestDto, @Req() req: any) { + return this.service.submit(req.bookingEngine.propertyId, dto); + } +} +``` + +- [ ] **Step 4: Implement server-authoritative submission** + +Load public config, require request mode, validate answers, call `RatePlanService.assertSellable`, check availability, call existing `BookingEngineService.quote`, resolve SetupIntent according to policy, insert immutable snapshots, emit `booking_request.created`, and return only the acknowledgement. In the existing `BookingEngineService.book`, require `bookingMode === 'instant'` before creating any guest, reservation, folio, or payment. + +- [ ] **Step 5: Run tests and confirm no instant regression** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request-submission.spec.ts src/modules/booking-engine/booking-engine.service.spec.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/modules/booking-request apps/api/src/modules/booking-engine/booking-engine.module.ts apps/api/src/modules/booking-engine/booking-engine.service.ts apps/api/src/modules/booking-engine/booking-engine.service.spec.ts apps/api/src/app.module.ts +git commit -m "feat(booking-requests): accept public submissions" +``` + +--- + +### Task 6: Implement staff reads, acceptance, and denial + +**Files:** +- Create: `apps/api/src/modules/booking-request/dto/list-booking-requests.dto.ts` +- Create: `apps/api/src/modules/booking-request/dto/accept-booking-request.dto.ts` +- Create: `apps/api/src/modules/booking-request/dto/deny-booking-request.dto.ts` +- Create: `apps/api/src/modules/booking-request/booking-request.controller.ts` +- Create: `apps/api/src/modules/booking-request/booking-request-decision.spec.ts` +- Modify: `apps/api/src/modules/booking-request/booking-request.service.ts` +- Modify: `apps/api/src/modules/booking-request/booking-request.module.ts` +- Modify: `apps/api/src/modules/reservation/reservation.service.ts` +- Modify: `apps/api/src/modules/folio/folio.service.ts` +- Modify: `apps/api/src/modules/guest/guest.service.ts` +- Modify: `apps/api/src/modules/ancillary/ancillary.service.ts` + +**Interfaces:** +- Produces `list`, `findById`, `accept`, and `deny` on `BookingRequestService`. +- Extends canonical reservation/folio creation to accept a caller transaction without changing existing callers. + +```ts +export type AcceptBookingRequestInput = { + priceSource: 'submitted' | 'current' | 'custom'; + customTotal?: string; + customReason?: string; +}; + +export type AuditActor = { + userId?: string; + userEmail?: string; + ipAddress?: string; +}; +``` + +- [ ] **Step 1: Write failing tenant, permission, and concurrency tests** + +Test `reservations.read` on list/detail; `reservations.write` on accept/deny; required `propertyId`; cross-property IDs returning not found; repricing choices; missing custom reason; no availability leaving `pending`; two concurrent accepts producing one reservation; accepted retry returning the linked reservation; and unresolved money blocking denial. + +- [ ] **Step 2: Run and verify failure** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request-decision.spec.ts` +Expected: FAIL because staff behavior is missing. + +- [ ] **Step 3: Add transaction-aware canonical creation** + +Change `GuestService.create(dto, tx?)`, `ReservationService.create(dto, opts, tx?)`, its internal FK/sellability/availability lookups, `FolioService.createAutoFolio(reservation, tx?)`, and the ancillary attach/ensure methods used by direct booking to use `tx ?? this.db`. Let the reservation caller suppress immediate webhook emission when an outer transaction is active; emit only after the outer commit. Preserve every existing caller and test. + +- [ ] **Step 4: Implement idempotent acceptance** + +Inside one database transaction, lock the property-scoped request, return its linked reservation when already accepted, reject denied, re-quote/recheck availability, resolve the accepted total, create guest/reservation/folio and selected ancillary links through transaction-aware canonical methods, link pre-acceptance payments to the folio, and atomically mark accepted. Emit webhook/audit after commit. + +- [ ] **Step 5: Implement denial with money-resolution guard** + +Lock the request, call `assertDenialMoneyResolved`, record reason/actor, and transition pending to denied. Never delete payments or the request. + +- [ ] **Step 6: Run focused and reservation regression tests** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request-decision.spec.ts src/modules/reservation/reservation-race.spec.ts src/modules/reservation/reservation-assert-sellable.spec.ts` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/modules/booking-request apps/api/src/modules/reservation/reservation.service.ts apps/api/src/modules/folio/folio.service.ts apps/api/src/modules/guest/guest.service.ts apps/api/src/modules/ancillary/ancillary.service.ts +git commit -m "feat(booking-requests): review and convert requests" +``` + +--- + +### Task 7: Implement installments and request-targeted payment operations + +**Files:** +- Create: `apps/api/src/modules/booking-request/dto/booking-request-payment.dto.ts` +- Create: `apps/api/src/modules/booking-request/booking-request-payment.service.ts` +- Create: `apps/api/src/modules/booking-request/booking-request-payment.spec.ts` +- Modify: `apps/api/src/modules/booking-request/booking-request.controller.ts` +- Modify: `apps/api/src/modules/booking-request/booking-request.module.ts` +- Modify: `apps/api/src/modules/payment/payment.service.ts` +- Modify: `apps/api/src/modules/payment/payment-ledger.ts` +- Modify: `apps/api/src/modules/payment/payment.service.spec.ts` + +**Interfaces:** +- Produces installment CRUD/allocation methods. +- Produces `chargeSavedCard`, `recordExternalPayment`, `refund`, `recordExternalReturn`, and `retainForDenial` methods. + +- [ ] **Step 1: Write failing financial tests** + +Cover fixed/percentage installments, several partial movements, manual/date/arrival/checkout milestones, no scheduled side effects, zero/negative rejection, gateway success/failure, additional-auth failure, stable idempotency, duplicate external references, partial refunds, external returns, retained amounts with reason, and folio relinking after acceptance. + +- [ ] **Step 2: Run and verify failure** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request-payment.spec.ts` +Expected: FAIL because the payment service is missing. + +- [ ] **Step 3: Implement installment persistence and allocation** + +Use database locks when allocating. Recompute each installment's derived unpaid/partial/paid state from allocation sums. Editing or deletion rejects allocated amounts. + +- [ ] **Step 4: Implement gateway charge as a two-phase operation** + +Insert a `pending` payment with property/request scope and idempotency key, commit, call `SavedPaymentMethodGateway.charge`, then atomically update to `captured` or `failed`. A repeated key returns the existing row and never calls the gateway again. + +- [ ] **Step 5: Implement external movements and resolutions** + +Record external payments as captured ledger rows with processed date/reference. Reuse existing refund ledger semantics for gateway refunds. Add explicit external-return and retained-resolution rows so denial can prove every net captured amount is resolved. + +- [ ] **Step 6: Run payment regression tests** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request-payment.spec.ts src/modules/payment/payment.service.spec.ts src/modules/payment/payment-ledger.spec.ts` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/modules/booking-request apps/api/src/modules/payment +git commit -m "feat(booking-requests): manage partial payments" +``` + +--- + +### Task 8: Persist transactional email and audit/webhook consequences + +**Files:** +- Create: `apps/api/src/modules/booking-request/booking-request-mailer.service.ts` +- Create: `apps/api/src/modules/booking-request/booking-request-mailer.spec.ts` +- Create: `apps/api/src/modules/booking-request/booking-request-email.templates.ts` +- Modify: `apps/api/src/modules/booking-request/booking-request.service.ts` +- Modify: `apps/api/src/modules/booking-request/booking-request-payment.service.ts` +- Modify: `apps/api/src/modules/booking-request/booking-request.controller.ts` +- Modify: `apps/api/src/modules/booking-request/booking-request.module.ts` +- Modify: `apps/api/src/modules/agent/guest-comms/email.module.ts` +- Modify: `packages/shared/src/index.ts` + +**Interfaces:** +- Produces `queue`, `deliver`, `retry`, and `listForRequest` on `BookingRequestMailerService`. +- Adds typed webhook events for request created/accepted/denied while reusing payment and reservation events. + +- [ ] **Step 1: Write failing delivery tests** + +Test receipt, accepted, denied, payment, refund, and failure templates; persisted pending-before-send behavior; sent/failed results; retry; no rollback of the originating action; and absence of request-management or authentication links. + +- [ ] **Step 2: Run and verify failure** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request-mailer.spec.ts` +Expected: FAIL because persistent delivery is missing. + +- [ ] **Step 3: Implement persistent delivery** + +```ts +async queue(input: QueueBookingRequestEmail): Promise { + const [delivery] = await this.db.insert(bookingRequestEmailDeliveries).values({ + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + kind: input.kind, + recipient: input.recipient, + subject: input.subject, + bodyText: input.bodyText, + status: 'pending', + }).returning({ id: bookingRequestEmailDeliveries.id }); + return delivery.id; +} +``` + +`deliver` loads by both delivery ID and `propertyId`, calls `EmailService`, and writes sent/failed state without throwing into the completed business transaction. + +- [ ] **Step 4: Wire consequences after committed actions** + +Submission, accept, deny, charge, external payment, refund, and failure queue/deliver their messages and emit sanitized webhook/audit payloads. Exclude answers, consent text, and payment tokens. + +- [ ] **Step 5: Run tests** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request-mailer.spec.ts src/modules/booking-request/booking-request-decision.spec.ts src/modules/booking-request/booking-request-payment.spec.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/modules/booking-request apps/api/src/modules/agent/guest-comms/email.module.ts packages/shared/src/index.ts +git commit -m "feat(booking-requests): send audited request emails" +``` + +--- + +### Task 9: Build the three-step public widget flow + +**Files:** +- Create: `apps/booking/src/pages/RequestApplication.tsx` +- Create: `apps/booking/src/pages/RequestApplication.test.tsx` +- Create: `apps/booking/src/pages/RequestPayment.tsx` +- Create: `apps/booking/src/pages/RequestPayment.test.tsx` +- Create: `apps/booking/src/pages/RequestReceived.tsx` +- Create: `apps/booking/src/components/ConfiguredQuestion.tsx` +- Create: `apps/booking/src/components/StripeSetupForm.tsx` +- Modify: `apps/booking/src/App.tsx` +- Modify: `apps/booking/src/api/client.ts` +- Modify: `apps/booking/src/api/types.ts` +- Modify: `apps/booking/src/context/BookingFlowContext.tsx` +- Modify: `apps/booking/src/pages/GuestDetails.tsx` +- Modify: `apps/booking/src/pages/Payment.tsx` +- Modify: `apps/booking/src/pages/Confirmation.tsx` + +**Interfaces:** +- Adds `bookingMode`, `paymentMethodCollection`, and `formQuestions` to `BookingConfig`. +- Adds `applicationAnswers`, `setupIntentId`, and request acknowledgement to booking flow state. +- Preserves the existing instant routes and components. + +- [ ] **Step 1: Write failing widget tests** + +Test instant mode unchanged; request application rendering all six question types; required validation; immutable state while navigating back; disabled skipping card UI; optional explicit skip; required blocking without successful setup; consent copy; request submission; and receipt page without manage link. + +- [ ] **Step 2: Run and verify failure** + +Run: `pnpm --filter @telivityhaip/booking test -- src/pages/RequestApplication.test.tsx src/pages/RequestPayment.test.tsx` +Expected: FAIL because request pages are missing. + +- [ ] **Step 3: Extend typed client and flow state** + +Add `bookingApi.createRequestPaymentMethodSetup` and `bookingApi.submitRequest`. Keep `bookingApi.book` untouched. Store answers and setup result in context; clear them in `reset`. + +- [ ] **Step 4: Implement mode-aware three-step routing** + +Search/results/room/extras remain shared. After selection, request mode routes to `/request/application`, then `/request/payment` when card policy is required/optional, then `/request/received`. Disabled card collection submits from the application confirmation action without loading Stripe. + +- [ ] **Step 5: Implement Stripe setup and consent** + +Render Payment Element with the server client secret and call `stripe.confirmSetup`. Submit only the SetupIntent ID; do not submit PaymentMethod IDs or card metadata from the browser. + +- [ ] **Step 6: Run widget suite** + +Run: `pnpm --filter @telivityhaip/booking test` +Run: `pnpm --filter @telivityhaip/booking typecheck` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/booking/src +git commit -m "feat(booking-widget): submit booking requests" +``` + +--- + +### Task 10: Add request configuration and question builder to the dashboard + +**Files:** +- Create: `apps/dashboard/src/components/admin/BookingQuestionBuilder.tsx` +- Create: `apps/dashboard/src/components/admin/BookingQuestionBuilder.test.tsx` +- Modify: `apps/dashboard/src/components/admin/BookingEngineSettings.tsx` +- Modify: `apps/dashboard/src/locales/en.json` +- Modify: `apps/dashboard/src/locales/es.json` +- Modify: `apps/dashboard/src/locales/de.json` +- Modify: `apps/dashboard/src/locales/fr.json` +- Modify: `apps/dashboard/src/locales/hr.json` +- Modify: `apps/dashboard/src/locales/it.json` +- Modify: `apps/dashboard/src/locales/pt-BR.json` +- Modify: `apps/dashboard/src/locales/sr-Latn.json` + +**Interfaces:** +- Consumes admin booking config from Task 3. +- Produces settings UI for booking mode, card policy, and ordered question definitions. + +- [ ] **Step 1: Write failing settings tests** + +Test defaults, mode/card-policy selectors, required-card warning without a key, add/edit/remove/reorder/disable question, per-type option editor, duplicate ID prevention, and saved payload shape. + +- [ ] **Step 2: Run and verify failure** + +Run: `pnpm --filter @telivityhaip/dashboard test -- src/components/admin/BookingQuestionBuilder.test.tsx` +Expected: FAIL because the builder is missing. + +- [ ] **Step 3: Implement the question builder** + +Use stable UUIDs generated when a question is created. Reordering changes only `order`; disabling retains historical identity. Show option editing only for single/multiple select. + +- [ ] **Step 4: Integrate settings and translations** + +Add request configuration near the existing enabled/auto-confirm controls and translate every new visible string in all supported locale files. + +- [ ] **Step 5: Run dashboard tests and typecheck** + +Run: `pnpm --filter @telivityhaip/dashboard test -- src/components/admin/BookingQuestionBuilder.test.tsx` +Run: `pnpm --filter @telivityhaip/dashboard typecheck` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/dashboard/src/components/admin apps/dashboard/src/locales +git commit -m "feat(dashboard): configure booking request forms" +``` + +--- + +### Task 11: Build the request queue and dedicated detail workspace + +**Files:** +- Create: `apps/dashboard/src/pages/BookingRequests.tsx` +- Create: `apps/dashboard/src/pages/BookingRequests.test.tsx` +- Create: `apps/dashboard/src/components/booking-requests/RequestOverview.tsx` +- Create: `apps/dashboard/src/components/booking-requests/RequestPayments.tsx` +- Create: `apps/dashboard/src/components/booking-requests/RequestMessages.tsx` +- Create: `apps/dashboard/src/components/booking-requests/RequestAudit.tsx` +- Create: `apps/dashboard/src/components/booking-requests/AcceptRequestModal.tsx` +- Create: `apps/dashboard/src/components/booking-requests/DenyRequestModal.tsx` +- Create: `apps/dashboard/src/components/booking-requests/PaymentActionModal.tsx` +- Modify: `apps/dashboard/src/App.tsx` +- Modify: `apps/dashboard/src/components/layout/Sidebar.tsx` +- Modify: `apps/dashboard/src/components/layout/Sidebar.test.tsx` +- Modify: `apps/dashboard/src/hooks/useRealtimeInvalidation.ts` +- Modify: all `apps/dashboard/src/locales/*.json` + +**Interfaces:** +- Consumes staff request/payment/message endpoints from Tasks 6–8. +- Produces `/booking-requests` queue and `/booking-requests/:id` tabbed detail routes. + +- [ ] **Step 1: Write failing page tests** + +Test permission-gated navigation, property-scoped queries, queue filters, card/status/amount cells, tab routing, submitted/current price comparison, custom price reason, duplicate-click disabling, denial resolution blocking, installment editing, Stripe/external action separation, positive amount validation, refund/retain UI, email retry, and audit display. + +- [ ] **Step 2: Run and verify failure** + +Run: `pnpm --filter @telivityhaip/dashboard test -- src/pages/BookingRequests.test.tsx src/components/layout/Sidebar.test.tsx` +Expected: FAIL because routes and pages are missing. + +- [ ] **Step 3: Implement queue and routing** + +Add a `reservations.read` navigation item under Front Desk. Build list and detail routes using TanStack Query keys containing `propertyId`, filters, and request ID. + +- [ ] **Step 4: Implement option-B detail tabs and actions** + +Keep Accept/Deny visible in the header. Use separate mutation modals for decisions and money. Payments & plan shows request movements before acceptance and the linked folio summary after acceptance. + +- [ ] **Step 5: Add realtime invalidation and translations** + +Map `booking_request.*`, `payment.*`, and linked `reservation.*` events to request queue/detail, payment, folio, message, and audit query keys. + +- [ ] **Step 6: Run dashboard suite and typecheck** + +Run: `pnpm --filter @telivityhaip/dashboard test` +Run: `pnpm --filter @telivityhaip/dashboard typecheck` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/dashboard/src +git commit -m "feat(dashboard): manage booking requests" +``` + +--- + +### Task 12: Add audited stay amendments from accepted requests + +**Files:** +- Create: `apps/api/src/modules/booking-request/dto/amend-booking-request-stay.dto.ts` +- Create: `apps/api/src/modules/booking-request/booking-request-amendment.spec.ts` +- Modify: `apps/api/src/modules/booking-request/booking-request.service.ts` +- Modify: `apps/api/src/modules/booking-request/booking-request.controller.ts` +- Modify: `apps/api/src/modules/reservation/dto/modify-reservation.dto.ts` +- Modify: `apps/api/src/modules/reservation/reservation.service.ts` +- Create: `apps/dashboard/src/components/booking-requests/ModifyStayModal.tsx` +- Create: `apps/dashboard/src/components/booking-requests/ModifyStayModal.test.tsx` +- Modify: `apps/dashboard/src/components/booking-requests/RequestOverview.tsx` +- Modify: `apps/dashboard/src/components/booking-requests/RequestPayments.tsx` + +**Interfaces:** +- Produces `BookingRequestService.amendStay(requestId, propertyId, input, actor)`. +- Reuses the submitted/current/custom price-choice contract and the existing reservation modification behavior. + +- [ ] **Step 1: Write failing amendment tests** + +Cover accepted-only behavior, property scope, complete-window availability, extension and shortening, authoritative repricing, three price choices, custom reason, unchanged original request snapshots/accepted price, reservation totals/dates update, folio summary refresh, audit values, webhook, and no duplicate room-revenue posting. + +- [ ] **Step 2: Run and verify failure** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request-amendment.spec.ts` +Expected: FAIL because amendment orchestration is missing. + +- [ ] **Step 3: Extract a reusable reservation amendment result** + +```ts +export type ReservationAmendmentResult = { + reservation: ReservationRow; + previousArrivalDate: string; + previousDepartureDate: string; + previousTotalAmount: string; + newTotalAmount: string; +}; +``` + +Extend `ReservationService.modify` to return this audit-ready result while preserving controller response compatibility. + +- [ ] **Step 4: Implement request-linked amendment orchestration** + +Load accepted request/reservation by property, quote the new stay, resolve price source, call canonical modification, insert an audit log with old/new dates and totals, and emit `reservation.modified`. Never update submitted/current/accepted request quote fields. + +- [ ] **Step 5: Implement dashboard modal** + +Show old/new dates, submitted accepted price, current quote, custom price and reason. On success invalidate request, reservation, availability, folio, and audit queries. + +- [ ] **Step 6: Run API and dashboard tests** + +Run: `pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request-amendment.spec.ts src/modules/reservation/reservation-ops.spec.ts` +Run: `pnpm --filter @telivityhaip/dashboard test -- src/components/booking-requests/ModifyStayModal.test.tsx` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/modules/booking-request apps/api/src/modules/reservation apps/dashboard/src/components/booking-requests +git commit -m "feat(booking-requests): amend accepted stays" +``` + +--- + +### Task 13: Verify the complete vertical slice and rollout guard + +**Files:** +- Create: `apps/api/src/modules/booking-request/booking-request.e2e-spec.ts` +- Create: `apps/booking/src/pages/RequestFlow.e2e.test.tsx` +- Modify: `README.md` +- Modify: `docs/test-stats.json` +- Modify: `docs/superpowers/specs/2026-08-24-booking-requests-design.md` only if the maintainer/KB confirmation requires an approved clarification. + +**Interfaces:** +- Verifies all preceding task interfaces together. +- Produces no new production interface. + +- [ ] **Step 1: Write the end-to-end API test** + +The test configures request mode/questions, submits with a saved card, verifies no reservation, creates 30/70 installments, takes a partial card payment, accepts with current price, records an external payment, posts a folio extra, extends the stay, and verifies request/reservation/folio/payment/email/audit state. + +- [ ] **Step 2: Run the end-to-end test and fix only integration defects** + +Run: `DATABASE_URL=postgresql://haip:haip@localhost:5432/haip_test REDIS_URL=redis://localhost:6379 CI=true pnpm --filter @telivityhaip/api test -- src/modules/booking-request/booking-request.e2e-spec.ts` +Expected: PASS. + +- [ ] **Step 3: Add widget flow integration coverage** + +Exercise request and instant configurations with mocked API/Stripe boundaries. Assert that disabled mode never loads Stripe and request receipt never renders a manage/cancel link. + +- [ ] **Step 4: Run all quality gates** + +Run: `pnpm build` +Run: `pnpm lint` +Run: `pnpm typecheck` +Run: `DATABASE_URL=postgresql://haip:haip@localhost:5432/haip_test REDIS_URL=redis://localhost:6379 CI=true pnpm test` +Expected: build/typecheck/tests succeed; lint has zero errors. + +- [ ] **Step 5: Run React diagnostics** + +Invoke the `react-doctor` skill against both `apps/dashboard` and `apps/booking`, address all actionable errors, then rerun their tests and typechecks. + +- [ ] **Step 6: Sync published test counts** + +Run: `pnpm readme:sync-tests` +Review only the generated README badge/count and `docs/test-stats.json` changes. + +- [ ] **Step 7: Verify rollout guard manually** + +With an existing/default property, confirm the widget still performs instant booking. With request mode enabled, confirm instant `POST /booking-engine/book` remains available only to the instant widget path and the request UI uses the dedicated request endpoint. Confirm switching back to instant preserves staff access to historical requests. + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/modules/booking-request/booking-request.e2e-spec.ts apps/booking/src/pages/RequestFlow.e2e.test.tsx README.md docs/test-stats.json docs/superpowers/specs/2026-08-24-booking-requests-design.md +git commit -m "test(booking-requests): verify the complete workflow" +``` + +--- + +## Final review checklist + +- [ ] Re-read issue #332, its maintainer confirmation, the cited KB sections, the spec, and this plan; resolve any mismatch before opening a PR. +- [ ] Confirm all public endpoints are publishable-key scoped and cannot enumerate/read requests. +- [ ] Confirm every staff nested-resource query filters `propertyId` directly. +- [ ] Confirm request acceptance and payment retries cannot duplicate external or database side effects. +- [ ] Confirm no raw card data, client-trusted card metadata, application answers, consent text, or payment token appears in logs/webhooks. +- [ ] Confirm instant booking remains the default and passes its original tests. +- [ ] Confirm request mode is usable end-to-end before exposing its setting. +- [ ] Use `superpowers:verification-before-completion` before claiming completion. +- [ ] Use `superpowers:requesting-code-review` before proposing merge. From 9e2e7a44f47d26c88f6adf58e0c7507f276727b9 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 15:20:23 +0200 Subject: [PATCH 03/87] feat(database): add booking request persistence --- .../src/booking-request-schema.spec.ts | 19 ++ .../src/migrations/0021_booking_requests.sql | 191 ++++++++++++++++++ packages/database/src/push-schema.ts | 136 +++++++++++++ .../database/src/schema/booking-engine.ts | 26 +++ .../database/src/schema/booking-request.ts | 168 +++++++++++++++ packages/database/src/schema/folio.ts | 8 +- packages/database/src/schema/index.ts | 24 ++- 7 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 packages/database/src/booking-request-schema.spec.ts create mode 100644 packages/database/src/migrations/0021_booking_requests.sql create mode 100644 packages/database/src/schema/booking-request.ts diff --git a/packages/database/src/booking-request-schema.spec.ts b/packages/database/src/booking-request-schema.spec.ts new file mode 100644 index 00000000..9cb58fad --- /dev/null +++ b/packages/database/src/booking-request-schema.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { + bookingEngineConfig, + bookingRequests, + bookingRequestInstallments, + payments, +} from './schema/index.js'; + +describe('booking request schema', () => { + it('exports request persistence and backward-compatible config columns', () => { + expect(bookingRequests.propertyId).toBeDefined(); + expect(bookingRequests.submittedQuoteSnapshot).toBeDefined(); + expect(bookingRequestInstallments.dueMilestone).toBeDefined(); + expect(bookingEngineConfig.bookingMode).toBeDefined(); + expect(bookingEngineConfig.paymentMethodCollection).toBeDefined(); + expect(payments.bookingRequestId).toBeDefined(); + expect(payments.idempotencyKey).toBeDefined(); + }); +}); diff --git a/packages/database/src/migrations/0021_booking_requests.sql b/packages/database/src/migrations/0021_booking_requests.sql new file mode 100644 index 00000000..26445223 --- /dev/null +++ b/packages/database/src/migrations/0021_booking_requests.sql @@ -0,0 +1,191 @@ +-- Booking Requests — separate request-first aggregate and neutral configuration. +-- This is a forward-only migration. It preserves existing instant booking and +-- payment behavior while adding optional request provenance. + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_status') THEN + CREATE TYPE booking_request_status AS ENUM ('pending', 'accepted', 'denied'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_price_source') THEN + CREATE TYPE booking_request_price_source AS ENUM ('submitted', 'current', 'custom'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_installment_milestone') THEN + CREATE TYPE booking_request_installment_milestone AS ENUM ('date', 'arrival', 'checkout', 'manual'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_installment_status') THEN + CREATE TYPE booking_request_installment_status AS ENUM ('unpaid', 'partial', 'paid'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_payment_resolution_type') THEN + CREATE TYPE booking_request_payment_resolution_type AS ENUM ('refund', 'external_return', 'retained'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_email_delivery_kind') THEN + CREATE TYPE booking_request_email_delivery_kind AS ENUM ('receipt', 'accepted', 'denied', 'payment', 'refund', 'failure'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_email_delivery_status') THEN + CREATE TYPE booking_request_email_delivery_status AS ENUM ('pending', 'sent', 'failed'); + END IF; +END $$; + +ALTER TABLE booking_engine_config ADD COLUMN IF NOT EXISTS booking_mode varchar(10); +ALTER TABLE booking_engine_config ADD COLUMN IF NOT EXISTS payment_method_collection varchar(10); +ALTER TABLE booking_engine_config ADD COLUMN IF NOT EXISTS form_questions jsonb; + +ALTER TABLE booking_engine_config ALTER COLUMN booking_mode SET DEFAULT 'instant'; +ALTER TABLE booking_engine_config ALTER COLUMN payment_method_collection SET DEFAULT 'disabled'; +ALTER TABLE booking_engine_config ALTER COLUMN form_questions SET DEFAULT '[]'::jsonb; + +UPDATE booking_engine_config +SET + booking_mode = COALESCE(booking_mode, 'instant'), + payment_method_collection = COALESCE(payment_method_collection, 'disabled'), + form_questions = COALESCE(form_questions, '[]'::jsonb); + +ALTER TABLE booking_engine_config ALTER COLUMN booking_mode SET NOT NULL; +ALTER TABLE booking_engine_config ALTER COLUMN payment_method_collection SET NOT NULL; +ALTER TABLE booking_engine_config ALTER COLUMN form_questions SET NOT NULL; + +CREATE TABLE IF NOT EXISTS booking_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + status booking_request_status NOT NULL DEFAULT 'pending', + arrival_date date NOT NULL, + departure_date date NOT NULL, + room_type_id uuid NOT NULL REFERENCES room_types(id), + rate_plan_id uuid NOT NULL REFERENCES rate_plans(id), + adults integer NOT NULL DEFAULT 1, + children integer NOT NULL DEFAULT 0, + guest_first_name varchar(100) NOT NULL, + guest_last_name varchar(100) NOT NULL, + guest_email varchar(255) NOT NULL, + guest_phone varchar(50), + special_requests text, + service_ids jsonb NOT NULL DEFAULT '[]'::jsonb, + form_snapshot jsonb NOT NULL DEFAULT '[]'::jsonb, + application_answers jsonb NOT NULL DEFAULT '{}'::jsonb, + submitted_quote_snapshot jsonb NOT NULL, + current_quote_snapshot jsonb, + currency_code varchar(3) NOT NULL, + stripe_customer_id varchar(255), + stripe_payment_method_id varchar(255), + card_last_four varchar(4), + card_brand varchar(20), + consent_text text, + consent_version varchar(40), + consented_at timestamptz, + accepted_price_source booking_request_price_source, + accepted_total numeric(12,2), + custom_price_reason text, + accepted_reservation_id uuid REFERENCES reservations(id), + accepted_folio_id uuid REFERENCES folios(id), + decided_by uuid, + decided_at timestamptz, + denial_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_accepted_reservation_unique + ON booking_requests (accepted_reservation_id); +CREATE INDEX IF NOT EXISTS booking_requests_property_status_idx + ON booking_requests (property_id, status); + +CREATE TABLE IF NOT EXISTS booking_request_installments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + booking_request_id uuid NOT NULL REFERENCES booking_requests(id), + label varchar(200) NOT NULL, + sort_order integer NOT NULL DEFAULT 0, + fixed_amount numeric(12,2), + percentage numeric(5,2), + resolved_amount numeric(12,2), + due_milestone booking_request_installment_milestone NOT NULL DEFAULT 'manual', + due_date date, + allocated_amount numeric(12,2) NOT NULL DEFAULT 0, + status booking_request_installment_status NOT NULL DEFAULT 'unpaid', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS booking_request_installments_property_request_idx + ON booking_request_installments (property_id, booking_request_id); + +ALTER TABLE payments ADD COLUMN IF NOT EXISTS booking_request_id uuid; +ALTER TABLE payments ADD COLUMN IF NOT EXISTS idempotency_key varchar(255); + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_booking_request_id_fkey') THEN + ALTER TABLE payments + ADD CONSTRAINT payments_booking_request_id_fkey + FOREIGN KEY (booking_request_id) REFERENCES booking_requests(id); + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS payments_property_idempotency_key_unique + ON payments (property_id, idempotency_key); + +-- Verify rather than repair legacy rows. A failed verification leaves data +-- untouched and makes the required migration action explicit to operators. +DO $$ BEGIN + IF EXISTS ( + SELECT 1 + FROM payments + WHERE folio_id IS NULL + AND house_account_id IS NULL + AND booking_request_id IS NULL + ) THEN + RAISE EXCEPTION 'Cannot add payments_financial_target_check: legacy payment rows have no folio, house account, or booking request target'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_financial_target_check') THEN + ALTER TABLE payments + ADD CONSTRAINT payments_financial_target_check + CHECK (folio_id IS NOT NULL OR house_account_id IS NOT NULL OR booking_request_id IS NOT NULL); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS booking_request_payment_allocations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + booking_request_id uuid NOT NULL REFERENCES booking_requests(id), + payment_id uuid NOT NULL REFERENCES payments(id), + installment_id uuid NOT NULL REFERENCES booking_request_installments(id), + amount numeric(12,2) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS booking_request_payment_allocations_payment_installment_unique + ON booking_request_payment_allocations (payment_id, installment_id); +CREATE INDEX IF NOT EXISTS booking_request_payment_allocations_property_request_idx + ON booking_request_payment_allocations (property_id, booking_request_id); + +CREATE TABLE IF NOT EXISTS booking_request_payment_resolutions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + booking_request_id uuid NOT NULL REFERENCES booking_requests(id), + payment_id uuid NOT NULL REFERENCES payments(id), + type booking_request_payment_resolution_type NOT NULL, + amount numeric(12,2) NOT NULL, + reason text, + resolved_by uuid, + resolved_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS booking_request_payment_resolutions_property_request_idx + ON booking_request_payment_resolutions (property_id, booking_request_id); + +CREATE TABLE IF NOT EXISTS booking_request_email_deliveries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + booking_request_id uuid NOT NULL REFERENCES booking_requests(id), + kind booking_request_email_delivery_kind NOT NULL, + status booking_request_email_delivery_status NOT NULL DEFAULT 'pending', + recipient varchar(255) NOT NULL, + subject varchar(500) NOT NULL, + body_text text NOT NULL, + error_message text, + attempts integer NOT NULL DEFAULT 0, + last_attempt_at timestamptz, + sent_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS booking_request_email_deliveries_property_request_idx + ON booking_request_email_deliveries (property_id, booking_request_id); diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index 30983fab..06835677 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -30,6 +30,13 @@ async function main() { // Idempotent add: append pix for DBs that already had payment_method without it `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid WHERE t.typname = 'payment_method' AND e.enumlabel = 'pix') THEN ALTER TYPE payment_method ADD VALUE 'pix'; END IF; END $$`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'payment_status') THEN CREATE TYPE payment_status AS ENUM ('pending','authorized','captured','settled','refunded','partially_refunded','failed','voided'); END IF; END $$`, + `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_status') THEN CREATE TYPE booking_request_status AS ENUM ('pending','accepted','denied'); END IF; END $$`, + `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_price_source') THEN CREATE TYPE booking_request_price_source AS ENUM ('submitted','current','custom'); END IF; END $$`, + `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_installment_milestone') THEN CREATE TYPE booking_request_installment_milestone AS ENUM ('date','arrival','checkout','manual'); END IF; END $$`, + `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_installment_status') THEN CREATE TYPE booking_request_installment_status AS ENUM ('unpaid','partial','paid'); END IF; END $$`, + `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_payment_resolution_type') THEN CREATE TYPE booking_request_payment_resolution_type AS ENUM ('refund','external_return','retained'); END IF; END $$`, + `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_email_delivery_kind') THEN CREATE TYPE booking_request_email_delivery_kind AS ENUM ('receipt','accepted','denied','payment','refund','failure'); END IF; END $$`, + `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_email_delivery_status') THEN CREATE TYPE booking_request_email_delivery_status AS ENUM ('pending','sent','failed'); END IF; END $$`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'housekeeping_task_status') THEN CREATE TYPE housekeeping_task_status AS ENUM ('pending','assigned','in_progress','completed','inspected','skipped'); END IF; END $$`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'housekeeping_task_type') THEN CREATE TYPE housekeeping_task_type AS ENUM ('checkout','stayover','deep_clean','inspection','turndown','maintenance'); END IF; END $$`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'hk_occupancy') THEN CREATE TYPE hk_occupancy AS ENUM ('unknown','vacant','occupied'); END IF; END $$`, @@ -1109,6 +1116,9 @@ async function main() { logo_media_id uuid, primary_color varchar(9), accent_color varchar(9), + booking_mode varchar(10) NOT NULL DEFAULT 'instant', + payment_method_collection varchar(10) NOT NULL DEFAULT 'disabled', + form_questions jsonb NOT NULL DEFAULT '[]'::jsonb, sellable_room_type_ids jsonb NOT NULL DEFAULT '[]'::jsonb, sellable_rate_plan_ids jsonb NOT NULL DEFAULT '[]'::jsonb, deposit_policy jsonb NOT NULL DEFAULT '{"type":"first_night","refundable":true}'::jsonb, @@ -1117,6 +1127,107 @@ async function main() { created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() )`, + // Booking Requests — separate request-first aggregate. Every row carries + // property scope because later services must filter it with every ID. + `CREATE TABLE IF NOT EXISTS booking_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + status booking_request_status NOT NULL DEFAULT 'pending', + arrival_date date NOT NULL, + departure_date date NOT NULL, + room_type_id uuid NOT NULL REFERENCES room_types(id), + rate_plan_id uuid NOT NULL REFERENCES rate_plans(id), + adults integer NOT NULL DEFAULT 1, + children integer NOT NULL DEFAULT 0, + guest_first_name varchar(100) NOT NULL, + guest_last_name varchar(100) NOT NULL, + guest_email varchar(255) NOT NULL, + guest_phone varchar(50), + special_requests text, + service_ids jsonb NOT NULL DEFAULT '[]'::jsonb, + form_snapshot jsonb NOT NULL DEFAULT '[]'::jsonb, + application_answers jsonb NOT NULL DEFAULT '{}'::jsonb, + submitted_quote_snapshot jsonb NOT NULL, + current_quote_snapshot jsonb, + currency_code varchar(3) NOT NULL, + stripe_customer_id varchar(255), + stripe_payment_method_id varchar(255), + card_last_four varchar(4), + card_brand varchar(20), + consent_text text, + consent_version varchar(40), + consented_at timestamptz, + accepted_price_source booking_request_price_source, + accepted_total numeric(12,2), + custom_price_reason text, + accepted_reservation_id uuid REFERENCES reservations(id), + accepted_folio_id uuid REFERENCES folios(id), + decided_by uuid, + decided_at timestamptz, + denial_reason text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_accepted_reservation_unique ON booking_requests (accepted_reservation_id)`, + `CREATE INDEX IF NOT EXISTS booking_requests_property_status_idx ON booking_requests (property_id, status)`, + `CREATE TABLE IF NOT EXISTS booking_request_installments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + booking_request_id uuid NOT NULL REFERENCES booking_requests(id), + label varchar(200) NOT NULL, + sort_order integer NOT NULL DEFAULT 0, + fixed_amount numeric(12,2), + percentage numeric(5,2), + resolved_amount numeric(12,2), + due_milestone booking_request_installment_milestone NOT NULL DEFAULT 'manual', + due_date date, + allocated_amount numeric(12,2) NOT NULL DEFAULT 0, + status booking_request_installment_status NOT NULL DEFAULT 'unpaid', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + )`, + `CREATE INDEX IF NOT EXISTS booking_request_installments_property_request_idx ON booking_request_installments (property_id, booking_request_id)`, + `CREATE TABLE IF NOT EXISTS booking_request_payment_allocations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + booking_request_id uuid NOT NULL REFERENCES booking_requests(id), + payment_id uuid NOT NULL REFERENCES payments(id), + installment_id uuid NOT NULL REFERENCES booking_request_installments(id), + amount numeric(12,2) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS booking_request_payment_allocations_payment_installment_unique ON booking_request_payment_allocations (payment_id, installment_id)`, + `CREATE INDEX IF NOT EXISTS booking_request_payment_allocations_property_request_idx ON booking_request_payment_allocations (property_id, booking_request_id)`, + `CREATE TABLE IF NOT EXISTS booking_request_payment_resolutions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + booking_request_id uuid NOT NULL REFERENCES booking_requests(id), + payment_id uuid NOT NULL REFERENCES payments(id), + type booking_request_payment_resolution_type NOT NULL, + amount numeric(12,2) NOT NULL, + reason text, + resolved_by uuid, + resolved_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now() + )`, + `CREATE INDEX IF NOT EXISTS booking_request_payment_resolutions_property_request_idx ON booking_request_payment_resolutions (property_id, booking_request_id)`, + `CREATE TABLE IF NOT EXISTS booking_request_email_deliveries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + booking_request_id uuid NOT NULL REFERENCES booking_requests(id), + kind booking_request_email_delivery_kind NOT NULL, + status booking_request_email_delivery_status NOT NULL DEFAULT 'pending', + recipient varchar(255) NOT NULL, + subject varchar(500) NOT NULL, + body_text text NOT NULL, + error_message text, + attempts integer NOT NULL DEFAULT 0, + last_attempt_at timestamptz, + sent_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + )`, + `CREATE INDEX IF NOT EXISTS booking_request_email_deliveries_property_request_idx ON booking_request_email_deliveries (property_id, booking_request_id)`, `CREATE UNIQUE INDEX IF NOT EXISTS bookings_property_external_channel_unique ON bookings (property_id, external_confirmation, channel_code) WHERE external_confirmation IS NOT NULL AND channel_code IS NOT NULL`, // Stay extras / packages `CREATE TABLE IF NOT EXISTS services ( @@ -1445,6 +1556,31 @@ async function main() { `ALTER TABLE charges ADD COLUMN IF NOT EXISTS parent_charge_id uuid`, `ALTER TABLE payments ALTER COLUMN folio_id DROP NOT NULL`, `ALTER TABLE payments ADD COLUMN IF NOT EXISTS house_account_id uuid`, + `ALTER TABLE payments ADD COLUMN IF NOT EXISTS booking_request_id uuid REFERENCES booking_requests(id)`, + `ALTER TABLE payments ADD COLUMN IF NOT EXISTS idempotency_key varchar(255)`, + `CREATE UNIQUE INDEX IF NOT EXISTS payments_property_idempotency_key_unique ON payments (property_id, idempotency_key)`, + `ALTER TABLE booking_engine_config ADD COLUMN IF NOT EXISTS booking_mode varchar(10)`, + `ALTER TABLE booking_engine_config ADD COLUMN IF NOT EXISTS payment_method_collection varchar(10)`, + `ALTER TABLE booking_engine_config ADD COLUMN IF NOT EXISTS form_questions jsonb`, + `ALTER TABLE booking_engine_config ALTER COLUMN booking_mode SET DEFAULT 'instant'`, + `ALTER TABLE booking_engine_config ALTER COLUMN payment_method_collection SET DEFAULT 'disabled'`, + `ALTER TABLE booking_engine_config ALTER COLUMN form_questions SET DEFAULT '[]'::jsonb`, + `UPDATE booking_engine_config SET booking_mode = COALESCE(booking_mode, 'instant'), payment_method_collection = COALESCE(payment_method_collection, 'disabled'), form_questions = COALESCE(form_questions, '[]'::jsonb)`, + `ALTER TABLE booking_engine_config ALTER COLUMN booking_mode SET NOT NULL`, + `ALTER TABLE booking_engine_config ALTER COLUMN payment_method_collection SET NOT NULL`, + `ALTER TABLE booking_engine_config ALTER COLUMN form_questions SET NOT NULL`, + `DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM payments + WHERE folio_id IS NULL AND house_account_id IS NULL AND booking_request_id IS NULL + ) THEN + RAISE EXCEPTION 'Cannot add payments_financial_target_check: legacy payment rows have no folio, house account, or booking request target'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_financial_target_check') THEN + ALTER TABLE payments ADD CONSTRAINT payments_financial_target_check + CHECK (folio_id IS NOT NULL OR house_account_id IS NOT NULL OR booking_request_id IS NOT NULL); + END IF; + END $$`, // Group linkage on reservations (KB 14.3) — added via ALTER to avoid a // circular FK at table-create time (group_profiles references nothing of // reservations, but reservations is created before group_profiles). diff --git a/packages/database/src/schema/booking-engine.ts b/packages/database/src/schema/booking-engine.ts index 25d5fc7e..9a48ba0f 100644 --- a/packages/database/src/schema/booking-engine.ts +++ b/packages/database/src/schema/booking-engine.ts @@ -1,6 +1,26 @@ import { pgTable, uuid, varchar, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core'; import { properties } from './property.js'; +export type BookingMode = 'instant' | 'request'; +export type PaymentMethodCollection = 'required' | 'optional' | 'disabled'; +export type BookingFormQuestionType = + | 'short_text' + | 'long_text' + | 'single_select' + | 'multi_select' + | 'yes_no' + | 'date'; + +export type BookingFormQuestion = { + id: string; + label: string; + type: BookingFormQuestionType; + options?: string[]; + order: number; + isActive: boolean; + isRequired: boolean; +}; + /** * Booking Engine — guest-facing direct (commission-free) booking. * @@ -56,6 +76,12 @@ export const bookingEngineConfig = pgTable('booking_engine_config', { logoMediaId: uuid('logo_media_id'), primaryColor: varchar('primary_color', { length: 9 }), accentColor: varchar('accent_color', { length: 9 }), + bookingMode: varchar('booking_mode', { length: 10 }).$type().notNull().default('instant'), + paymentMethodCollection: varchar('payment_method_collection', { length: 10 }) + .$type() + .notNull() + .default('disabled'), + formQuestions: jsonb('form_questions').$type().notNull().default([]), // Allow-lists: only these room types / rate plans are publicly sellable. Empty // = nothing is sold (fail-closed) until the operator opts inventory in. sellableRoomTypeIds: jsonb('sellable_room_type_ids').$type().notNull().default([]), diff --git a/packages/database/src/schema/booking-request.ts b/packages/database/src/schema/booking-request.ts new file mode 100644 index 00000000..6237cc02 --- /dev/null +++ b/packages/database/src/schema/booking-request.ts @@ -0,0 +1,168 @@ +import { + date, + integer, + jsonb, + numeric, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; +import type { BookingFormQuestion } from './booking-engine.js'; +import { payments, folios } from './folio.js'; +import { properties } from './property.js'; +import { ratePlans } from './rate-plan.js'; +import { reservations } from './reservation.js'; +import { roomTypes } from './room.js'; + +export const bookingRequestStatusEnum = pgEnum('booking_request_status', [ + 'pending', + 'accepted', + 'denied', +]); + +export const bookingRequestPriceSourceEnum = pgEnum('booking_request_price_source', [ + 'submitted', + 'current', + 'custom', +]); + +export const bookingRequestInstallmentMilestoneEnum = pgEnum('booking_request_installment_milestone', [ + 'date', + 'arrival', + 'checkout', + 'manual', +]); + +export const bookingRequestInstallmentStatusEnum = pgEnum('booking_request_installment_status', [ + 'unpaid', + 'partial', + 'paid', +]); + +export const bookingRequestPaymentResolutionTypeEnum = pgEnum('booking_request_payment_resolution_type', [ + 'refund', + 'external_return', + 'retained', +]); + +export const bookingRequestEmailDeliveryKindEnum = pgEnum('booking_request_email_delivery_kind', [ + 'receipt', + 'accepted', + 'denied', + 'payment', + 'refund', + 'failure', +]); + +export const bookingRequestEmailDeliveryStatusEnum = pgEnum('booking_request_email_delivery_status', [ + 'pending', + 'sent', + 'failed', +]); + +export const bookingRequests = pgTable('booking_requests', { + id: uuid('id').primaryKey().defaultRandom(), + propertyId: uuid('property_id').notNull().references(() => properties.id), + status: bookingRequestStatusEnum('status').notNull().default('pending'), + arrivalDate: date('arrival_date').notNull(), + departureDate: date('departure_date').notNull(), + roomTypeId: uuid('room_type_id').notNull().references(() => roomTypes.id), + ratePlanId: uuid('rate_plan_id').notNull().references(() => ratePlans.id), + adults: integer('adults').notNull().default(1), + children: integer('children').notNull().default(0), + guestFirstName: varchar('guest_first_name', { length: 100 }).notNull(), + guestLastName: varchar('guest_last_name', { length: 100 }).notNull(), + guestEmail: varchar('guest_email', { length: 255 }).notNull(), + guestPhone: varchar('guest_phone', { length: 50 }), + specialRequests: text('special_requests'), + serviceIds: jsonb('service_ids').$type().notNull().default([]), + formSnapshot: jsonb('form_snapshot').$type().notNull().default([]), + applicationAnswers: jsonb('application_answers').$type>().notNull().default({}), + submittedQuoteSnapshot: jsonb('submitted_quote_snapshot').notNull(), + currentQuoteSnapshot: jsonb('current_quote_snapshot'), + currencyCode: varchar('currency_code', { length: 3 }).notNull(), + stripeCustomerId: varchar('stripe_customer_id', { length: 255 }), + stripePaymentMethodId: varchar('stripe_payment_method_id', { length: 255 }), + cardLastFour: varchar('card_last_four', { length: 4 }), + cardBrand: varchar('card_brand', { length: 20 }), + consentText: text('consent_text'), + consentVersion: varchar('consent_version', { length: 40 }), + consentedAt: timestamp('consented_at', { withTimezone: true }), + acceptedPriceSource: bookingRequestPriceSourceEnum('accepted_price_source'), + acceptedTotal: numeric('accepted_total', { precision: 12, scale: 2 }), + customPriceReason: text('custom_price_reason'), + acceptedReservationId: uuid('accepted_reservation_id').references(() => reservations.id), + acceptedFolioId: uuid('accepted_folio_id').references(() => folios.id), + decidedBy: uuid('decided_by'), + decidedAt: timestamp('decided_at', { withTimezone: true }), + denialReason: text('denial_reason'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + acceptedReservationUnique: uniqueIndex('booking_requests_accepted_reservation_unique') + .on(table.acceptedReservationId), +})); + +export const bookingRequestInstallments = pgTable('booking_request_installments', { + id: uuid('id').primaryKey().defaultRandom(), + propertyId: uuid('property_id').notNull().references(() => properties.id), + bookingRequestId: uuid('booking_request_id').notNull().references(() => bookingRequests.id), + label: varchar('label', { length: 200 }).notNull(), + sortOrder: integer('sort_order').notNull().default(0), + fixedAmount: numeric('fixed_amount', { precision: 12, scale: 2 }), + percentage: numeric('percentage', { precision: 5, scale: 2 }), + resolvedAmount: numeric('resolved_amount', { precision: 12, scale: 2 }), + dueMilestone: bookingRequestInstallmentMilestoneEnum('due_milestone').notNull().default('manual'), + dueDate: date('due_date'), + allocatedAmount: numeric('allocated_amount', { precision: 12, scale: 2 }).notNull().default('0'), + status: bookingRequestInstallmentStatusEnum('status').notNull().default('unpaid'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const bookingRequestPaymentAllocations = pgTable('booking_request_payment_allocations', { + id: uuid('id').primaryKey().defaultRandom(), + propertyId: uuid('property_id').notNull().references(() => properties.id), + bookingRequestId: uuid('booking_request_id').notNull().references(() => bookingRequests.id), + paymentId: uuid('payment_id').notNull().references(() => payments.id), + installmentId: uuid('installment_id').notNull().references(() => bookingRequestInstallments.id), + amount: numeric('amount', { precision: 12, scale: 2 }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + paymentInstallmentUnique: uniqueIndex('booking_request_payment_allocations_payment_installment_unique') + .on(table.paymentId, table.installmentId), +})); + +export const bookingRequestPaymentResolutions = pgTable('booking_request_payment_resolutions', { + id: uuid('id').primaryKey().defaultRandom(), + propertyId: uuid('property_id').notNull().references(() => properties.id), + bookingRequestId: uuid('booking_request_id').notNull().references(() => bookingRequests.id), + paymentId: uuid('payment_id').notNull().references(() => payments.id), + type: bookingRequestPaymentResolutionTypeEnum('type').notNull(), + amount: numeric('amount', { precision: 12, scale: 2 }).notNull(), + reason: text('reason'), + resolvedBy: uuid('resolved_by'), + resolvedAt: timestamp('resolved_at', { withTimezone: true }).notNull().defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const bookingRequestEmailDeliveries = pgTable('booking_request_email_deliveries', { + id: uuid('id').primaryKey().defaultRandom(), + propertyId: uuid('property_id').notNull().references(() => properties.id), + bookingRequestId: uuid('booking_request_id').notNull().references(() => bookingRequests.id), + kind: bookingRequestEmailDeliveryKindEnum('kind').notNull(), + status: bookingRequestEmailDeliveryStatusEnum('status').notNull().default('pending'), + recipient: varchar('recipient', { length: 255 }).notNull(), + subject: varchar('subject', { length: 500 }).notNull(), + bodyText: text('body_text').notNull(), + errorMessage: text('error_message'), + attempts: integer('attempts').notNull().default(0), + lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }), + sentAt: timestamp('sent_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/packages/database/src/schema/folio.ts b/packages/database/src/schema/folio.ts index e0404b39..d499861c 100644 --- a/packages/database/src/schema/folio.ts +++ b/packages/database/src/schema/folio.ts @@ -3,6 +3,7 @@ import { properties } from './property.js'; import { reservations, bookings } from './reservation.js'; import { guests } from './guest.js'; import { houseAccounts } from './house-account.js'; +import { bookingRequests } from './booking-request.js'; /** * Folio types (KB 5.4): @@ -154,6 +155,8 @@ export const payments = pgTable('payments', { // (KB 13 — house accounts reuse the payments ledger but have no folio). folioId: uuid('folio_id').references(() => folios.id), houseAccountId: uuid('house_account_id').references(() => houseAccounts.id), + bookingRequestId: uuid('booking_request_id').references(() => bookingRequests.id), + idempotencyKey: varchar('idempotency_key', { length: 255 }), method: paymentMethodEnum('method').notNull(), status: paymentStatusEnum('status').notNull().default('pending'), @@ -182,4 +185,7 @@ export const payments = pgTable('payments', { createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}); +}, (table) => ({ + propertyIdempotencyKeyUnique: uniqueIndex('payments_property_idempotency_key_unique') + .on(table.propertyId, table.idempotencyKey), +})); diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 28b05eca..ebe3fa2a 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -238,7 +238,29 @@ export { bookingEngineCredentials, bookingEngineConfig, } from './booking-engine.js'; -export type { DepositPolicy } from './booking-engine.js'; +export type { + BookingFormQuestion, + BookingFormQuestionType, + BookingMode, + DepositPolicy, + PaymentMethodCollection, +} from './booking-engine.js'; + +// Booking Requests — request-first direct booking persistence +export { + bookingRequestStatusEnum, + bookingRequestPriceSourceEnum, + bookingRequestInstallmentMilestoneEnum, + bookingRequestInstallmentStatusEnum, + bookingRequestPaymentResolutionTypeEnum, + bookingRequestEmailDeliveryKindEnum, + bookingRequestEmailDeliveryStatusEnum, + bookingRequests, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + bookingRequestPaymentResolutions, + bookingRequestEmailDeliveries, +} from './booking-request.js'; // Stay extras / packages (upsells & ancillaries) export { From 5453ccbda3cc92f495b5bbe6218ef939244b5e97 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 15:30:50 +0200 Subject: [PATCH 04/87] feat(booking-requests): define lifecycle and money rules --- .../booking-request-money.spec.ts | 83 +++++++ .../booking-request/booking-request-money.ts | 230 ++++++++++++++++++ .../booking-request-state.spec.ts | 22 ++ .../booking-request/booking-request-state.ts | 18 ++ 4 files changed, 353 insertions(+) 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-state.spec.ts create mode 100644 apps/api/src/modules/booking-request/booking-request-state.ts 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..19737b79 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-money.spec.ts @@ -0,0 +1,83 @@ +import Decimal from 'decimal.js'; +import { describe, expect, it } from 'vitest'; +import { + assertAllocationAmount, + assertDenialMoneyResolved, + resolveAcceptedTotal, + resolveInstallmentAmount, +} from './booking-request-money'; + +describe('booking request money', () => { + 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('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/); + }); + + 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' }, + ], + )).not.toThrow(); + expect(() => assertDenialMoneyResolved( + [{ id: 'payment-1', status: 'captured', amount: '100.00' }], + [{ paymentId: 'payment-1', type: 'retained', amount: '99.99' }], + )).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/); + }); +}); 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..fa411d0f --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-money.ts @@ -0,0 +1,230 @@ +import { ConflictException } from '@nestjs/common'; +import Decimal from 'decimal.js'; + +export type MoneyValue = string | number | 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; +}; + +export type AllocationAmountInput = { + amount: MoneyValue; + movementAmount: MoneyValue; + installmentAmount: MoneyValue; +}; + +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; +}; + +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 currency(value: Decimal): Decimal { + return value.toDecimalPlaces(2); +} + +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); + 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'); + } +} + +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; + positive(movement.netAmount ?? movement.amount, 'Captured payment amount'); + return true; +} + +/** + * 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 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) { + const amount = positive(resolution.amount, `${resolution.type} resolution`); + const key = resolution.paymentId ?? resolution.movementId; + if (key != null && !capturedKeys.has(key)) { + throw new ConflictException(`Resolution references unknown captured movement '${key}'`); + } + if (key == null) { + unkeyed = unkeyed.plus(amount); + } else { + sums.set(key, (sums.get(key) ?? new Decimal(0)).plus(amount)); + } + } + + if (captured.length === 0) { + if (resolutions.length > 0) { + 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-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}'`, + ); + } +} From 76fbc57b737616eeb380cea14453c658bf87146b Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 15:36:49 +0200 Subject: [PATCH 05/87] fix(booking-requests): tighten denial and allocation rules --- .../booking-request-money.spec.ts | 34 +++++++++++- .../booking-request/booking-request-money.ts | 55 +++++++++++++++++-- 2 files changed, 82 insertions(+), 7 deletions(-) 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 index 19737b79..715a92b2 100644 --- a/apps/api/src/modules/booking-request/booking-request-money.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-money.spec.ts @@ -54,6 +54,14 @@ describe('booking request money', () => { .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', () => { @@ -61,12 +69,12 @@ describe('booking request money', () => { [{ 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' }, + { 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' }], + [{ paymentId: 'payment-1', type: 'retained', amount: '99.99', reason: 'Partial retention' }], )).toThrow(/payment-1/); }); @@ -80,4 +88,26 @@ describe('booking request money', () => { [{ 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(); + }); }); diff --git a/apps/api/src/modules/booking-request/booking-request-money.ts b/apps/api/src/modules/booking-request/booking-request-money.ts index fa411d0f..9e5be0ff 100644 --- a/apps/api/src/modules/booking-request/booking-request-money.ts +++ b/apps/api/src/modules/booking-request/booking-request-money.ts @@ -1,7 +1,7 @@ import { ConflictException } from '@nestjs/common'; import Decimal from 'decimal.js'; -export type MoneyValue = string | number | Decimal; +export type MoneyValue = string | Decimal; export type BookingRequestPriceSource = 'submitted' | 'current' | 'custom'; export type ResolveAcceptedTotalInput = { @@ -32,6 +32,13 @@ 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 = { @@ -50,6 +57,7 @@ export type DenialResolution = { movementId?: string; type: 'refund' | 'external_return' | 'retained'; amount: MoneyValue; + reason?: string | null; }; function decimal(value: MoneyValue, field: string): Decimal { @@ -70,6 +78,14 @@ function positive(value: MoneyValue, field: string): Decimal { 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): Decimal { return value.toDecimalPlaces(2); } @@ -165,6 +181,25 @@ export function assertAllocationAmount(input: AllocationAmountInput): void { 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 { @@ -177,8 +212,7 @@ function isCapturedMovement(movement: CapturedMovement): boolean { } // Refund/correction child rows are not independent captured money. if (movement.type === 'refund' || movement.type === 'external_return') return false; - positive(movement.netAmount ?? movement.amount, 'Captured payment amount'); - return true; + return decimal(movement.netAmount ?? movement.amount, 'Captured payment amount').gt(0); } /** @@ -190,16 +224,23 @@ export function assertDenialMoneyResolved( 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 && !capturedKeys.has(key)) { + 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 { @@ -208,7 +249,11 @@ export function assertDenialMoneyResolved( } if (captured.length === 0) { - if (resolutions.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; From 667e7ea810fb592578cad9f4c6ad8714c9e5e855 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 15:38:25 +0200 Subject: [PATCH 06/87] fix(booking-requests): reject negative movement balances --- .../booking-request/booking-request-money.spec.ts | 11 +++++++++++ .../modules/booking-request/booking-request-money.ts | 10 +++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) 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 index 715a92b2..eaf79ffd 100644 --- a/apps/api/src/modules/booking-request/booking-request-money.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-money.spec.ts @@ -110,4 +110,15 @@ describe('booking request money', () => { [{ 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 index 9e5be0ff..c43bd8ac 100644 --- a/apps/api/src/modules/booking-request/booking-request-money.ts +++ b/apps/api/src/modules/booking-request/booking-request-money.ts @@ -212,7 +212,15 @@ function isCapturedMovement(movement: CapturedMovement): boolean { } // Refund/correction child rows are not independent captured money. if (movement.type === 'refund' || movement.type === 'external_return') return false; - return decimal(movement.netAmount ?? movement.amount, 'Captured payment amount').gt(0); + 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); } /** From 57402d3eefbf13723beaf9cbbb19ac5d2a676ccb Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 15:45:41 +0200 Subject: [PATCH 07/87] feat(booking-engine): configure request forms --- .../booking-engine-config.service.ts | 49 ++++- .../booking-engine.service.spec.ts | 3 + .../booking-form-questions.spec.ts | 204 ++++++++++++++++++ .../booking-engine/booking-form-questions.ts | 156 ++++++++++++++ .../booking-engine/dto/be-admin.dto.ts | 55 +++++ 5 files changed, 463 insertions(+), 4 deletions(-) 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 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..16e0be97 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,16 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common'; 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 type { + BookingFormQuestion, + BookingMode, + DepositPolicy, + PaymentMethodCollection, +} from '@telivityhaip/database'; import { DRIZZLE } from '../../database/database.module'; import { hashBookingKey } from '../auth/booking-key.guard'; +import { validateQuestionDefinitions } from './booking-form-questions'; // Crockford base32 (no I/L/O/U) — unambiguous when copied by a human. const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; @@ -30,6 +36,9 @@ export interface UpdateConfigInput { depositPolicy?: DepositPolicy; autoConfirm?: boolean; stripePublishableKey?: string | null; + bookingMode?: BookingMode; + paymentMethodCollection?: PaymentMethodCollection; + formQuestions?: BookingFormQuestion[]; } @Injectable() @@ -57,6 +66,11 @@ export class BookingEngineConfigService { */ async getPublicConfig(propertyId: string) { const cfg = await this.getConfig(propertyId); + const formQuestions = validateQuestionDefinitions( + (cfg.formQuestions ?? []) as BookingFormQuestion[], + ) + .filter((question) => question.isActive) + .sort((a, b) => a.order - b.order); return { propertyId: cfg.propertyId, isEnabled: cfg.isEnabled, @@ -68,14 +82,41 @@ export class BookingEngineConfigService { stripePublishableKey: cfg.stripePublishableKey, sellableRoomTypeIds: cfg.sellableRoomTypeIds as string[], sellableRatePlanIds: cfg.sellableRatePlanIds as string[], + bookingMode: cfg.bookingMode as BookingMode, + paymentMethodCollection: cfg.paymentMethodCollection as PaymentMethodCollection, + formQuestions, }; } async updateConfig(propertyId: string, input: UpdateConfigInput) { - await this.getConfig(propertyId); // ensure row exists + const current = await this.getConfig(propertyId); // ensure row exists + const bookingMode = input.bookingMode ?? current.bookingMode as BookingMode; + const paymentMethodCollection = input.paymentMethodCollection + ?? current.paymentMethodCollection as PaymentMethodCollection; + const stripePublishableKey = input.stripePublishableKey === undefined + ? current.stripePublishableKey + : input.stripePublishableKey; + const formQuestions = input.formQuestions === undefined + ? current.formQuestions as BookingFormQuestion[] + : validateQuestionDefinitions(input.formQuestions); + + if (bookingMode === 'request' + && paymentMethodCollection === 'required' + && (!stripePublishableKey || stripePublishableKey.trim().length === 0)) { + throw new BadRequestException( + 'A Stripe publishable key is required when request-mode card collection is required', + ); + } + const [updated] = await this.db .update(bookingEngineConfig) - .set({ ...input, updatedAt: new Date() }) + .set({ + ...input, + bookingMode, + paymentMethodCollection, + formQuestions, + updatedAt: new Date(), + }) .where(eq(bookingEngineConfig.propertyId, propertyId)) .returning(); return updated; 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..d3f20bf0 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,6 +15,9 @@ 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 }), }; 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..eb5eec31 --- /dev/null +++ b/apps/api/src/modules/booking-engine/booking-form-questions.spec.ts @@ -0,0 +1,204 @@ +import { BadRequestException } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { describe, expect, it, vi } from 'vitest'; +import type { BookingFormQuestion } from '@telivityhaip/database'; +import { UpdateBookingEngineConfigDto } from './dto/be-admin.dto'; +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, +}; + +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); + }); +}); + +describe('booking form DTO validation', () => { + it('validates nested question ids and limits the form to fifty definitions', async () => { + const malformed = plainToInstance(UpdateBookingEngineConfigDto, { + formQuestions: [{ + id: 'not-a-uuid', + label: 'Purpose', + type: 'single_select', + options: ['Leisure'], + order: 0, + isActive: true, + isRequired: true, + }], + }); + const oversized = plainToInstance(UpdateBookingEngineConfigDto, { + 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, + })), + }); + + expect(await validate(malformed)).not.toEqual([]); + expect(await validate(oversized)).not.toEqual([]); + }); +}); + +function makeConfigService(row: Record) { + const returning = vi.fn().mockResolvedValue([row]); + const where = vi.fn().mockReturnValue({ returning }); + const set = vi.fn().mockReturnValue({ where }); + const update = vi.fn().mockReturnValue({ set }); + const selectWhere = vi.fn().mockResolvedValue([row]); + const from = vi.fn().mockReturnValue({ where: selectWhere }); + const select = vi.fn().mockReturnValue({ from }); + const db = { select, update }; + + return { + service: new BookingEngineConfigService(db as any), + update, + set, + }; +} + +describe('BookingEngineConfigService request settings', () => { + const configRow = { + 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 }, + stripePublishableKey: 'pk_test_123', + sellableRoomTypeIds: [], + sellableRatePlanIds: [], + autoConfirm: false, + bookingMode: 'request' as const, + paymentMethodCollection: 'optional' as const, + formQuestions: [ + { ...arrivalQuestion, order: 2 }, + { ...breakfastQuestion, order: 1, isActive: false }, + { id: 'notes', label: 'Notes', type: 'long_text' as const, order: 3, isActive: true, isRequired: false }, + ], + }; + + it('returns public request settings with only active questions in display order', async () => { + const { service } = makeConfigService(configRow); + + await expect(service.getPublicConfig(configRow.propertyId)).resolves.toMatchObject({ + bookingMode: 'request', + paymentMethodCollection: 'optional', + formQuestions: [ + { id: 'arrival', order: 2 }, + { id: 'notes', order: 3 }, + ], + }); + }); + + it('rejects required request card collection without a publishable card key', async () => { + const { service, update } = makeConfigService({ ...configRow, stripePublishableKey: null }); + + await expect(service.updateConfig(configRow.propertyId, { + paymentMethodCollection: 'required', + })).rejects.toThrow(/publishable/i); + expect(update).not.toHaveBeenCalled(); + }); +}); 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..2fc074af --- /dev/null +++ b/apps/api/src/modules/booking-engine/booking-form-questions.ts @@ -0,0 +1,156 @@ +import { BadRequestException } from '@nestjs/common'; +import type { BookingFormQuestion, 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; + +function invalid(message: string): never { + throw new BadRequestException(message); +} + +function normalized(value: string): string { + return value.trim().toLocaleLowerCase(); +} + +function isBlank(value: unknown): boolean { + return value === undefined + || value === null + || (typeof value === 'string' && value.trim().length === 0) + || (Array.isArray(value) && value.length === 0); +} + +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 validateQuestionDefinitions(questions: BookingFormQuestion[]): BookingFormQuestion[] { + 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((question) => { + if (!question || 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_TYPES.includes(question.type)) { + invalid(`Question '${question.label}' has an unsupported type`); + } + if (!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`); + } + + const options = question.options; + if (SELECT_TYPES.has(question.type)) { + if (!Array.isArray(options) || options.length === 0) { + invalid(`Select question '${question.label}' requires at least one option`); + } + const normalizedOptions = new Set(); + for (const option of options) { + if (typeof option !== 'string' || option.trim().length === 0) { + 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 { ...question, ...(options ? { options: [...options] } : {}) }; + }); +} + +/** Validates the public answer payload against the current active form schema. */ +export function validateApplicationAnswers( + questions: BookingFormQuestion[], + answers: Record, +): Record { + const definitions = validateQuestionDefinitions(questions); + 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 (isBlank(answer)) { + 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`); + 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) + || 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..e3e543c3 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 @@ -11,6 +11,7 @@ import { MaxLength, Min, ValidateNested, + ArrayMaxSize, } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -40,6 +41,42 @@ export class CreateBookingKeyDto { label!: string; } +export class BookingFormQuestionDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + id!: string; + + @ApiProperty({ maxLength: 200 }) + @IsString() + @MaxLength(200) + label!: string; + + @ApiProperty({ enum: ['short_text', 'long_text', 'single_select', 'multi_select', 'yes_no', 'date'] }) + @IsIn(['short_text', 'long_text', 'single_select', 'multi_select', 'yes_no', 'date']) + type!: 'short_text' | 'long_text' | 'single_select' | 'multi_select' | 'yes_no' | 'date'; + + @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; +} + /** Admin: update per-property booking engine config. */ export class UpdateBookingEngineConfigDto { @ApiPropertyOptional() @@ -91,6 +128,24 @@ 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) + @ValidateNested({ each: true }) + @Type(() => BookingFormQuestionDto) + formQuestions?: BookingFormQuestionDto[]; + @ApiPropertyOptional({ description: 'Stripe PUBLISHABLE key (safe to expose)' }) @IsOptional() @IsString() From 7e65e4ca37d07deda9dab8d71489f15cabb7ea39 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 15:54:44 +0200 Subject: [PATCH 08/87] fix(booking-engine): tighten request form settings --- .../booking-engine-config.service.ts | 10 +++--- .../booking-form-questions.spec.ts | 31 +++++++++++++++++++ .../booking-engine/booking-form-questions.ts | 23 ++++++++------ .../booking-engine/dto/be-admin.dto.ts | 18 ++++++++--- 4 files changed, 63 insertions(+), 19 deletions(-) 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 16e0be97..e5b50afd 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 @@ -97,7 +97,7 @@ export class BookingEngineConfigService { ? current.stripePublishableKey : input.stripePublishableKey; const formQuestions = input.formQuestions === undefined - ? current.formQuestions as BookingFormQuestion[] + ? undefined : validateQuestionDefinitions(input.formQuestions); if (bookingMode === 'request' @@ -111,10 +111,10 @@ export class BookingEngineConfigService { const [updated] = await this.db .update(bookingEngineConfig) .set({ - ...input, - bookingMode, - paymentMethodCollection, - formQuestions, + ...Object.fromEntries( + Object.entries(input).filter(([, value]) => value !== undefined), + ), + ...(input.formQuestions === undefined ? {} : { formQuestions }), updatedAt: new Date(), }) .where(eq(bookingEngineConfig.propertyId, propertyId)) 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 index eb5eec31..2814e718 100644 --- a/apps/api/src/modules/booking-engine/booking-form-questions.spec.ts +++ b/apps/api/src/modules/booking-engine/booking-form-questions.spec.ts @@ -110,6 +110,20 @@ describe('validateApplicationAnswers', () => { 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', () => { @@ -201,4 +215,21 @@ describe('BookingEngineConfigService request settings', () => { })).rejects.toThrow(/publishable/i); expect(update).not.toHaveBeenCalled(); }); + + 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, + }); + + 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'); + }); }); diff --git a/apps/api/src/modules/booking-engine/booking-form-questions.ts b/apps/api/src/modules/booking-engine/booking-form-questions.ts index 2fc074af..88f59f17 100644 --- a/apps/api/src/modules/booking-engine/booking-form-questions.ts +++ b/apps/api/src/modules/booking-engine/booking-form-questions.ts @@ -21,13 +21,6 @@ function normalized(value: string): string { return value.trim().toLocaleLowerCase(); } -function isBlank(value: unknown): boolean { - return value === undefined - || value === null - || (typeof value === 'string' && value.trim().length === 0) - || (Array.isArray(value) && value.length === 0); -} - 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`); @@ -116,7 +109,7 @@ export function validateApplicationAnswers( const validated: Record = {}; for (const question of activeQuestions) { const answer = answers[question.id]; - if (isBlank(answer)) { + if (!Object.prototype.hasOwnProperty.call(answers, question.id)) { if (question.isRequired) { invalid(`${question.label} is required`); } @@ -127,6 +120,10 @@ export function validateApplicationAnswers( 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)) { @@ -134,8 +131,14 @@ export function validateApplicationAnswers( } break; case 'multi_select': - if (!Array.isArray(answer) - || answer.some((value) => typeof value !== 'string' || !question.options!.includes(value)) + 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`); } 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 e3e543c3..682900c8 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 @@ -15,6 +15,16 @@ import { } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import type { BookingFormQuestion, BookingFormQuestionType } from '@telivityhaip/database'; + +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'] }) @@ -41,7 +51,7 @@ export class CreateBookingKeyDto { label!: string; } -export class BookingFormQuestionDto { +export class BookingFormQuestionDto implements BookingFormQuestion { @ApiProperty({ format: 'uuid' }) @IsUUID() id!: string; @@ -51,9 +61,9 @@ export class BookingFormQuestionDto { @MaxLength(200) label!: string; - @ApiProperty({ enum: ['short_text', 'long_text', 'single_select', 'multi_select', 'yes_no', 'date'] }) - @IsIn(['short_text', 'long_text', 'single_select', 'multi_select', 'yes_no', 'date']) - type!: 'short_text' | 'long_text' | 'single_select' | 'multi_select' | 'yes_no' | 'date'; + @ApiProperty({ enum: BOOKING_FORM_QUESTION_TYPES }) + @IsIn(BOOKING_FORM_QUESTION_TYPES) + type!: BookingFormQuestionType; @ApiPropertyOptional({ type: [String], maxItems: 50 }) @IsOptional() From 20dd540d038bd485d333631755c11ae352e037b3 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 15:58:27 +0200 Subject: [PATCH 09/87] fix(booking-engine): lock request config updates --- .../booking-engine-config.service.ts | 72 +++++++++++-------- .../booking-form-questions.spec.ts | 19 ++++- 2 files changed, 60 insertions(+), 31 deletions(-) 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 e5b50afd..164242db 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 @@ -89,37 +89,49 @@ export class BookingEngineConfigService { } async updateConfig(propertyId: string, input: UpdateConfigInput) { - const current = await this.getConfig(propertyId); // ensure row exists - const bookingMode = input.bookingMode ?? current.bookingMode as BookingMode; - const paymentMethodCollection = input.paymentMethodCollection - ?? current.paymentMethodCollection as PaymentMethodCollection; - const stripePublishableKey = input.stripePublishableKey === undefined - ? current.stripePublishableKey - : input.stripePublishableKey; - const formQuestions = input.formQuestions === undefined - ? undefined - : validateQuestionDefinitions(input.formQuestions); - - if (bookingMode === 'request' - && paymentMethodCollection === 'required' - && (!stripePublishableKey || stripePublishableKey.trim().length === 0)) { - throw new BadRequestException( - 'A Stripe publishable key is required when request-mode card collection is required', - ); - } + await this.getConfig(propertyId); // ensure a row exists before locking it - const [updated] = await this.db - .update(bookingEngineConfig) - .set({ - ...Object.fromEntries( - Object.entries(input).filter(([, value]) => value !== undefined), - ), - ...(input.formQuestions === undefined ? {} : { formQuestions }), - updatedAt: new Date(), - }) - .where(eq(bookingEngineConfig.propertyId, propertyId)) - .returning(); - return updated; + 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 bookingMode = input.bookingMode ?? current.bookingMode as BookingMode; + const paymentMethodCollection = input.paymentMethodCollection + ?? current.paymentMethodCollection as PaymentMethodCollection; + const stripePublishableKey = input.stripePublishableKey === undefined + ? current.stripePublishableKey + : input.stripePublishableKey; + const formQuestions = input.formQuestions === undefined + ? undefined + : validateQuestionDefinitions(input.formQuestions); + + if (bookingMode === 'request' + && paymentMethodCollection === 'required' + && (!stripePublishableKey || stripePublishableKey.trim().length === 0)) { + throw new BadRequestException( + 'A Stripe publishable key is required when request-mode card collection is required', + ); + } + + const [updated] = await tx + .update(bookingEngineConfig) + .set({ + ...Object.fromEntries( + Object.entries(input).filter(([, value]) => value !== undefined), + ), + ...(input.formQuestions === undefined ? {} : { formQuestions }), + updatedAt: new Date(), + }) + .where(eq(bookingEngineConfig.propertyId, propertyId)) + .returning(); + return updated; + }); } // --- Publishable keys --- 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 index 2814e718..16e553ec 100644 --- a/apps/api/src/modules/booking-engine/booking-form-questions.spec.ts +++ b/apps/api/src/modules/booking-engine/booking-form-questions.spec.ts @@ -163,12 +163,20 @@ function makeConfigService(row: Record) { const selectWhere = vi.fn().mockResolvedValue([row]); const from = vi.fn().mockReturnValue({ where: selectWhere }); const select = vi.fn().mockReturnValue({ from }); - const db = { select, update }; + const lock = vi.fn().mockResolvedValue([row]); + const lockedWhere = vi.fn().mockReturnValue({ for: lock }); + const lockedFrom = vi.fn().mockReturnValue({ where: lockedWhere }); + const lockedSelect = vi.fn().mockReturnValue({ from: lockedFrom }); + const tx = { select: lockedSelect, update }; + const transaction = vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)); + const db = { select, update, transaction }; return { service: new BookingEngineConfigService(db as any), update, set, + transaction, + lock, }; } @@ -232,4 +240,13 @@ describe('BookingEngineConfigService request settings', () => { 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' }); + + expect(transaction).toHaveBeenCalledOnce(); + expect(lock).toHaveBeenCalledWith('update'); + }); }); From 53da16542e81d91fa2dab6f4f444e8c465ec71d6 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 16:08:17 +0200 Subject: [PATCH 10/87] feat(payments): save and charge request payment methods --- .../saved-payment-method-gateway.interface.ts | 34 +++ .../mock-saved-payment-method.gateway.spec.ts | 121 ++++++++++ .../mock-saved-payment-method.gateway.ts | 69 ++++++ .../api/src/modules/payment/payment.module.ts | 18 +- ...tripe-saved-payment-method.gateway.spec.ts | 227 ++++++++++++++++++ .../stripe-saved-payment-method.gateway.ts | 164 +++++++++++++ 6 files changed, 631 insertions(+), 2 deletions(-) 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/stripe-saved-payment-method.gateway.spec.ts create mode 100644 apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts 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..ea845cc4 --- /dev/null +++ b/apps/api/src/modules/payment/interfaces/saved-payment-method-gateway.interface.ts @@ -0,0 +1,34 @@ +export type SavedPaymentMethod = { + setupIntentId: string; + customerId: string; + paymentMethodId: string; + cardLastFour: string; + cardBrand: string; +}; + +export type SavedPaymentMethodChargeInput = { + customerId: string; + paymentMethodId: string; + amount: string; + currencyCode: string; + idempotencyKey: string; +}; + +export type SavedPaymentMethodChargeResult = { + success: boolean; + transactionId: string; + requiresAction: boolean; + errorMessage?: string; +}; + +export interface SavedPaymentMethodGateway { + createSetup(email: string, idempotencyKey: string): Promise<{ + setupIntentId: string; + clientSecret: string; + customerId: string; + }>; + resolveSetup(setupIntentId: string): 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..866b5b0e --- /dev/null +++ b/apps/api/src/modules/payment/mock-saved-payment-method.gateway.spec.ts @@ -0,0 +1,121 @@ +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', () => { + 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'); + const retry = await gateway.createSetup('guest@example.com', 'request-card:req_123'); + + expect(retry).toEqual(first); + await expect(gateway.resolveSetup(first.setupIntentId)).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')).rejects.toThrow( + /Unknown mock SetupIntent/, + ); + }); + + it('returns an idempotent successful off-session charge result', async () => { + const gateway = new MockSavedPaymentMethodGateway(); + const input = { + customerId: 'cus_mock_trusted', + paymentMethodId: 'pm_mock_trusted', + 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); + }); +}); + +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); + }); +}); 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..01af7ebc --- /dev/null +++ b/apps/api/src/modules/payment/mock-saved-payment-method.gateway.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { createHash } from 'crypto'; +import type { + SavedPaymentMethod, + SavedPaymentMethodChargeInput, + SavedPaymentMethodChargeResult, + SavedPaymentMethodGateway, +} from './interfaces/saved-payment-method-gateway.interface'; + +@Injectable() +export class MockSavedPaymentMethodGateway implements SavedPaymentMethodGateway { + private readonly setupsByKey = new Map(); + private readonly paymentMethodsBySetupId = new Map(); + private readonly chargesByKey = new Map(); + + async createSetup(_email: string, idempotencyKey: string): Promise<{ + setupIntentId: string; + clientSecret: string; + customerId: string; + }> { + const existing = this.setupsByKey.get(idempotencyKey); + if (existing) return existing.setup; + + const suffix = this.stableSuffix(idempotencyKey); + const setup = { + setupIntentId: `seti_mock_${suffix}`, + clientSecret: `seti_mock_${suffix}_secret_mock`, + customerId: `cus_mock_${suffix}`, + }; + const paymentMethod: SavedPaymentMethod = { + setupIntentId: setup.setupIntentId, + customerId: setup.customerId, + paymentMethodId: `pm_mock_${suffix}`, + cardLastFour: '4242', + cardBrand: 'visa', + }; + this.setupsByKey.set(idempotencyKey, { setup, paymentMethod }); + this.paymentMethodsBySetupId.set(setup.setupIntentId, paymentMethod); + return setup; + } + + async resolveSetup(setupIntentId: string): Promise { + const paymentMethod = this.paymentMethodsBySetupId.get(setupIntentId); + if (!paymentMethod) { + throw new Error(`Unknown mock SetupIntent '${setupIntentId}'`); + } + return paymentMethod; + } + + async charge(input: SavedPaymentMethodChargeInput): Promise { + const existing = this.chargesByKey.get(input.idempotencyKey); + if (existing) return existing; + + const result = { + success: true, + transactionId: `pi_mock_${this.stableSuffix(input.idempotencyKey)}`, + requiresAction: false, + } satisfies SavedPaymentMethodChargeResult; + this.chargesByKey.set(input.idempotencyKey, result); + return result; + } + + private stableSuffix(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 24); + } +} diff --git a/apps/api/src/modules/payment/payment.module.ts b/apps/api/src/modules/payment/payment.module.ts index 25da1da5..19d6b722 100644 --- a/apps/api/src/modules/payment/payment.module.ts +++ b/apps/api/src/modules/payment/payment.module.ts @@ -6,7 +6,13 @@ 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'; /** * Payment module with configurable gateway. @@ -29,7 +35,15 @@ import { createPaymentGateway } from './payment-gateway.factory'; useFactory: (configService: ConfigService) => createPaymentGateway(configService), inject: [ConfigService], }, + { + provide: SAVED_PAYMENT_METHOD_GATEWAY, + useFactory: (configService: ConfigService) => + resolvePaymentGatewayProvider(configService) === 'mock' + ? new MockSavedPaymentMethodGateway() + : new StripeSavedPaymentMethodGateway(configService), + inject: [ConfigService], + }, ], - exports: [PaymentService], + exports: [PaymentService, SAVED_PAYMENT_METHOD_GATEWAY], }) export class PaymentModule {} 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..de157583 --- /dev/null +++ b/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.spec.ts @@ -0,0 +1,227 @@ +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', () => { + 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'), + ).resolves.toEqual({ + setupIntentId: 'seti_trusted', + clientSecret: 'seti_secret_safe_for_guest', + customerId: 'cus_trusted', + }); + expect(stripe.customers.create).toHaveBeenCalledWith( + { email: 'guest@example.com' }, + { idempotencyKey: 'request-card:req_123' }, + ); + expect(stripe.setupIntents.create).toHaveBeenCalledWith( + { + customer: 'cus_trusted', + usage: 'off_session', + payment_method_types: ['card'], + }, + { idempotencyKey: 'request-card:req_123' }, + ); + }); + + 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')).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', + }); + stripe.paymentMethods.retrieve.mockResolvedValue({ + id: 'pm_trusted', + type: 'card', + card: { + brand: 'visa', + last4: '4242', + exp_month: 12, + exp_year: 2035, + fingerprint: 'server-only-fingerprint', + }, + }); + + const result = await gateway.resolveSetup('seti_trusted'); + + 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', + }); + stripe.paymentMethods.retrieve.mockResolvedValue({ + id: 'pm_bank', + type: 'us_bank_account', + card: null, + }); + + await expect(gateway.resolveSetup('seti_bank')).rejects.toThrow(/card payment method/); + }); + + 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', + 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', + confirm: true, + off_session: true, + capture_method: 'automatic', + automatic_payment_methods: { + enabled: true, + allow_redirects: 'never', + }, + }, + { idempotencyKey: 'request-charge:payment_123' }, + ); + }); + + 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', + }); + }); +}); 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..b239bd2e --- /dev/null +++ b/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts @@ -0,0 +1,164 @@ +import { Injectable } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; +import Decimal from 'decimal.js'; +import Stripe from 'stripe'; +import type { + SavedPaymentMethod, + SavedPaymentMethodChargeInput, + SavedPaymentMethodChargeResult, + SavedPaymentMethodGateway, +} from './interfaces/saved-payment-method-gateway.interface'; + +@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): Promise<{ + setupIntentId: string; + clientSecret: string; + customerId: string; + }> { + const options: Stripe.RequestOptions = { idempotencyKey }; + const customer = await this.stripe.customers.create({ email }, options); + const setupIntent = await this.stripe.setupIntents.create( + { + customer: customer.id, + usage: 'off_session', + payment_method_types: ['card'], + }, + options, + ); + + 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, + }; + } + + async resolveSetup(setupIntentId: string): Promise { + const setupIntent = await this.stripe.setupIntents.retrieve(setupIntentId); + if (setupIntent.status !== 'succeeded') { + throw new Error(`Stripe SetupIntent '${setupIntentId}' has not succeeded`); + } + + 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); + 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 paymentIntent = await this.stripe.paymentIntents.create( + { + amount: this.toMinorUnits(input.amount), + currency: input.currencyCode.toLowerCase(), + customer: input.customerId, + payment_method: input.paymentMethodId, + 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); + } + + return { + success: false, + transactionId: stripePaymentIntent?.id ?? '', + requiresAction: false, + errorMessage: error instanceof Error ? error.message : 'Stripe charge failed', + }; + } + } + + private expandedId(value: string | { id: string } | null): string | null { + return typeof value === 'string' ? value : value?.id ?? null; + } + + private toMinorUnits(amount: string): number { + return new Decimal(amount).mul(100).toDecimalPlaces(0).toNumber(); + } + + 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); + } + 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; + } +} From 23d08c6797155842a0bc66b75559553bf98909dd Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 16:17:29 +0200 Subject: [PATCH 11/87] fix(payments): harden saved payment method gateway --- .../mock-saved-payment-method.gateway.spec.ts | 36 +++++++ .../api/src/modules/payment/payment.module.ts | 17 +++- ...tripe-saved-payment-method.gateway.spec.ts | 94 +++++++++++++++++++ .../stripe-saved-payment-method.gateway.ts | 45 ++++++++- ...nsupported-saved-payment-method.gateway.ts | 33 +++++++ 5 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/modules/payment/unsupported-saved-payment-method.gateway.ts 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 index 866b5b0e..f53001b7 100644 --- 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 @@ -118,4 +118,40 @@ describe('PaymentModule saved-payment-method registration', () => { 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); + + await expect(gateway.createSetup('guest@example.com', 'setup-key')).rejects.toThrow( + /Saved payment methods are not supported.*adyen/, + ); + await expect(gateway.resolveSetup('seti_test')).rejects.toThrow( + /Saved payment methods are not supported.*adyen/, + ); + await expect(gateway.charge({ + customerId: 'cus_test', + paymentMethodId: 'pm_test', + 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/payment.module.ts b/apps/api/src/modules/payment/payment.module.ts index 19d6b722..e17dcc0d 100644 --- a/apps/api/src/modules/payment/payment.module.ts +++ b/apps/api/src/modules/payment/payment.module.ts @@ -13,6 +13,19 @@ import { 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. @@ -38,9 +51,7 @@ import { StripeSavedPaymentMethodGateway } from './stripe-saved-payment-method.g { provide: SAVED_PAYMENT_METHOD_GATEWAY, useFactory: (configService: ConfigService) => - resolvePaymentGatewayProvider(configService) === 'mock' - ? new MockSavedPaymentMethodGateway() - : new StripeSavedPaymentMethodGateway(configService), + createSavedPaymentMethodGateway(configService), inject: [ConfigService], }, ], 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 index de157583..550621d0 100644 --- 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 @@ -101,6 +101,7 @@ describe('StripeSavedPaymentMethodGateway', () => { stripe.paymentMethods.retrieve.mockResolvedValue({ id: 'pm_trusted', type: 'card', + customer: { id: 'cus_trusted' }, card: { brand: 'visa', last4: '4242', @@ -135,12 +136,35 @@ describe('StripeSavedPaymentMethodGateway', () => { stripe.paymentMethods.retrieve.mockResolvedValue({ id: 'pm_bank', type: 'us_bank_account', + customer: 'cus_trusted', card: null, }); await expect(gateway.resolveSetup('seti_bank')).rejects.toThrow(/card payment method/); }); + 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', + }); + 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')).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', @@ -176,6 +200,76 @@ describe('StripeSavedPaymentMethodGateway', () => { ); }); + it.each([ + { currencyCode: 'JPY', amount: '123', expectedMinorUnits: 123 }, + { currencyCode: 'BHD', amount: '1.234', expectedMinorUnits: 1234 }, + ])( + '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' }, + { currencyCode: 'BHD', amount: '1.2345' }, + ])( + '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 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', 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 index b239bd2e..60039edc 100644 --- a/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts +++ b/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts @@ -68,6 +68,13 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa } 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`); } @@ -83,10 +90,11 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa async charge(input: SavedPaymentMethodChargeInput): Promise { try { + const currencyCode = this.normalizeCurrencyCode(input.currencyCode); const paymentIntent = await this.stripe.paymentIntents.create( { - amount: this.toMinorUnits(input.amount), - currency: input.currencyCode.toLowerCase(), + amount: this.toMinorUnits(input.amount, currencyCode), + currency: currencyCode.toLowerCase(), customer: input.customerId, payment_method: input.paymentMethodId, confirm: true, @@ -120,8 +128,37 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa return typeof value === 'string' ? value : value?.id ?? null; } - private toMinorUnits(amount: string): number { - return new Decimal(amount).mul(100).toDecimalPlaces(0).toNumber(); + 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 Error(`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 Error(`Unable to resolve minor-unit exponent for '${currencyCode}'`); + } + const minorUnits = new Decimal(amount).mul(new Decimal(10).pow(exponent)); + if (!minorUnits.isInteger()) { + throw new Error(`Amount '${amount}' ${currencyCode} has fractional minor units`); + } + const value = minorUnits.toNumber(); + if (!Number.isSafeInteger(value)) { + throw new Error(`Amount '${amount}' ${currencyCode} exceeds the safe Stripe integer range`); + } + return value; } private mapPaymentIntent(paymentIntent: Stripe.PaymentIntent): SavedPaymentMethodChargeResult { 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..ba4e4ca4 --- /dev/null +++ b/apps/api/src/modules/payment/unsupported-saved-payment-method.gateway.ts @@ -0,0 +1,33 @@ +import type { + SavedPaymentMethod, + SavedPaymentMethodChargeInput, + SavedPaymentMethodChargeResult, + SavedPaymentMethodGateway, +} 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, + ): Promise<{ setupIntentId: string; clientSecret: string; customerId: string }> { + throw this.unsupported(); + } + + async resolveSetup(_setupIntentId: string): 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.`, + ); + } +} From 2c16ed436b2376556300743fbce130cea0f30723 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 16:35:58 +0200 Subject: [PATCH 12/87] feat(booking-requests): accept public submissions --- apps/api/src/app.module.ts | 2 + .../booking-engine/booking-engine.module.ts | 2 +- .../booking-engine.service.spec.ts | 20 + .../booking-engine/booking-engine.service.ts | 5 + .../booking-request-public.controller.ts | 42 ++ .../booking-request-submission.spec.ts | 476 ++++++++++++++++++ .../booking-request/booking-request.module.ts | 30 ++ .../booking-request.service.ts | 309 ++++++++++++ .../dto/create-request-card-setup.dto.ts | 18 + .../dto/submit-booking-request.dto.ts | 120 +++++ 10 files changed, 1023 insertions(+), 1 deletion(-) 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-submission.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/create-request-card-setup.dto.ts create mode 100644 apps/api/src/modules/booking-request/dto/submit-booking-request.dto.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2b00fcf7..ae6a7b21 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -35,6 +35,7 @@ import { PolicyModule } from './modules/policy/policy.module'; import { GroupsModule } from './modules/groups/groups.module'; import { AdminModule } from './modules/admin/admin.module'; import { BookingEngineModule } from './modules/booking-engine/booking-engine.module'; +import { BookingRequestModule } from './modules/booking-request/booking-request.module'; import { ImportModule } from './modules/import/import.module'; import { MigrationModule } from './modules/migration/migration.module'; import { AccountingExportModule } from './modules/accounting-export/accounting-export.module'; @@ -90,6 +91,7 @@ const imports: any[] = [ GroupsModule, AdminModule, BookingEngineModule, + BookingRequestModule, ImportModule, MigrationModule, AccountingExportModule, 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 d3f20bf0..30c50973 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 @@ -151,6 +151,7 @@ describe('BookingEngineService.book', () => { const { svc, config } = makeService(); config.getPublicConfig.mockResolvedValue({ isEnabled: true, + bookingMode: 'instant', sellableRoomTypeIds: [], sellableRatePlanIds: [RP], depositPolicy: { type: 'first_night', refundable: true }, @@ -169,6 +170,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; 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..7f1dd2df 100644 --- a/apps/api/src/modules/booking-engine/booking-engine.service.ts +++ b/apps/api/src/modules/booking-engine/booking-engine.service.ts @@ -327,6 +327,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. 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..08853348 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-public.controller.ts @@ -0,0 +1,42 @@ +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') + @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-submission.spec.ts b/apps/api/src/modules/booking-request/booking-request-submission.spec.ts new file mode 100644 index 00000000..d121f929 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-submission.spec.ts @@ -0,0 +1,476 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, +} from '@nestjs/common'; +import { bookingRequests } from '@telivityhaip/database'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +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 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 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, + stripePublishableKey: 'pk_test_public', + 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: SubmitBookingRequestDto = { + 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' }, +}; + +function makeHarness() { + let insertedValues: Record | undefined; + const returning = vi.fn().mockResolvedValue([{ id: REQUEST_ID }]); + const values = vi.fn((input: Record) => { + insertedValues = input; + return { returning }; + }); + const db = { + insert: vi.fn((table: unknown) => { + if (table !== bookingRequests) { + throw new Error('Submission attempted a non-request database write'); + } + return { values }; + }), + }; + 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', + }), + resolveSetup: vi.fn().mockResolvedValue({ + setupIntentId: 'seti_trusted', + customerId: 'cus_trusted', + paymentMethodId: 'pm_trusted', + cardLastFour: '4242', + cardBrand: 'visa', + }), + }; + const webhook = { emit: vi.fn().mockResolvedValue(undefined) }; + 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], + ); + + return { + service, + db, + config, + availability, + ratePlan, + bookingEngine, + savedPaymentMethod, + webhook, + values, + 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); + }); +}); + +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', + idempotencyKey: 'widget-attempt-1', + })).rejects.toBeInstanceOf(errorType); + 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', + idempotencyKey: 'widget-attempt-1', + })).resolves.toEqual({ + setupIntentId: 'seti_trusted', + clientSecret: 'seti_trusted_secret_value', + }); + expect(harness.savedPaymentMethod.createSetup).toHaveBeenCalledWith( + 'ada@example.com', + `booking-request:${PROPERTY_ID}:widget-attempt-1`, + ); + }); +}); + +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('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('allows optional card collection to be skipped explicitly by omitting setup data', async () => { + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + 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(['required', 'optional'] as const)( + 'resolves trusted saved-card details and stores consent for %s collection', + async (paymentMethodCollection) => { + harness.config.getPublicConfig.mockResolvedValue({ + ...structuredClone(publicConfig), + 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', + ); + expect(harness.insertedValues).toMatchObject({ + 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).toHaveBeenCalledOnce(); + 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, + 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('emits a sanitized created event after the request write', async () => { + await harness.service.submit(PROPERTY_ID, submitDto); + + expect(harness.webhook.emit).toHaveBeenCalledWith( + 'booking_request.created', + 'booking_request', + REQUEST_ID, + { requestId: REQUEST_ID, status: 'pending' }, + PROPERTY_ID, + ); + expect(harness.values.mock.invocationCallOrder[0]).toBeLessThan( + harness.webhook.emit.mock.invocationCallOrder[0]!, + ); + expect(JSON.stringify(harness.webhook.emit.mock.calls)).not.toContain('Leisure'); + expect(JSON.stringify(harness.webhook.emit.mock.calls)).not.toContain('consent'); + expect(JSON.stringify(harness.webhook.emit.mock.calls)).not.toContain('seti_'); + }); +}); 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..2c99ed98 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +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 { WebhookModule } from '../webhook/webhook.module'; +import { BookingRequestPublicController } from './booking-request-public.controller'; +import { BookingRequestService } from './booking-request.service'; + +@Module({ + imports: [ + BookingEngineModule, + ReservationModule, + RatePlanModule, + PaymentModule, + WebhookModule, + ], + controllers: [BookingRequestPublicController], + providers: [ + BookingRequestService, + BookingKeyGuard, + BookingEngineScopeGuard, + BookingThrottleGuard, + ], + exports: [BookingRequestService], +}) +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..a68079a3 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request.service.ts @@ -0,0 +1,309 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Inject, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import { bookingRequests } from '@telivityhaip/database'; +import type { PaymentMethodCollection } from '@telivityhaip/database'; +import type { WebhookEvent } from '@telivityhaip/shared'; +import { DRIZZLE } from '../../database/database.module'; +import { BookingEngineConfigService } from '../booking-engine/booking-engine-config.service'; +import { BookingEngineService } from '../booking-engine/booking-engine.service'; +import { validateApplicationAnswers } 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 { AvailabilityService } from '../reservation/availability.service'; +import { WebhookService } from '../webhook/webhook.service'; +import type { CreateRequestCardSetupDto } from './dto/create-request-card-setup.dto'; +import type { SubmitBookingRequestDto } from './dto/submit-booking-request.dto'; + +export type BookingRequestAcknowledgement = { + requestId: string; + status: 'pending'; + message: string; +}; + +type PublicRequestConfig = Awaited< + ReturnType +>; + +type CardSnapshot = { + stripeCustomerId: string | null; + stripePaymentMethodId: string | null; + cardLastFour: string | null; + cardBrand: string | null; + consentText: string | null; + consentVersion: string | null; + consentedAt: Date | null; +}; + +type BookingRequestDatabase = { + insert(table: typeof bookingRequests): { + values(input: typeof bookingRequests.$inferInsert): { + returning(selection: { id: typeof bookingRequests.id }): Promise>; + }; + }; +}; + +const ACKNOWLEDGEMENT_MESSAGE = + 'Your booking request has been received and is pending review.'; + +@Injectable() +export class BookingRequestService { + 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, + ) {} + + async createPaymentMethodSetup( + propertyId: string, + dto: CreateRequestCardSetupDto, + ): Promise<{ setupIntentId: string; clientSecret: string }> { + 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'); + } + if (!config.stripePublishableKey?.trim()) { + throw new BadRequestException('Payment method collection is unavailable for this property'); + } + + const setup = await this.savedPaymentMethodGateway.createSetup( + dto.guestEmail, + `booking-request:${propertyId}:${dto.idempotencyKey}`, + ); + return { + setupIntentId: setup.setupIntentId, + clientSecret: setup.clientSecret, + }; + } + + async submit( + propertyId: string, + dto: SubmitBookingRequestDto, + ): Promise { + 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.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, + }); + const card = await this.resolveCard(config.paymentMethodCollection, dto); + + const [request] = await this.db + .insert(bookingRequests) + .values({ + propertyId, + 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), + currentQuoteSnapshot: null, + currencyCode: quote.currencyCode, + ...card, + }) + .returning({ id: bookingRequests.id }); + + if (!request) { + throw new InternalServerErrorException('Booking request could not be created'); + } + + await this.webhookService.emit( + // Request webhook types are completed with the staff lifecycle events. + 'booking_request.created' as unknown as WebhookEvent, + 'booking_request', + request.id, + { requestId: request.id, status: 'pending' }, + propertyId, + ); + + return { + requestId: request.id, + status: 'pending', + message: ACKNOWLEDGEMENT_MESSAGE, + }; + } + + 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 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, + ): Promise { + if (policy === 'disabled' || !dto.setupIntentId) { + return this.emptyCardSnapshot(); + } + + let savedMethod: SavedPaymentMethod; + try { + savedMethod = await this.savedPaymentMethodGateway.resolveSetup(dto.setupIntentId); + } catch { + throw new BadRequestException('Payment method setup is incomplete or invalid'); + } + + return { + 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 { + 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; + } +} 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..26684b2f --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/create-request-card-setup.dto.ts @@ -0,0 +1,18 @@ +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 key for this setup attempt.', + example: 'request-application-018f6f8f', + }) + @IsString() + @MinLength(1) + @MaxLength(200) + idempotencyKey!: string; +} 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..9a9ec8d4 --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/submit-booking-request.dto.ts @@ -0,0 +1,120 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsArray, + IsBoolean, + IsDateString, + IsEmail, + IsInt, + IsObject, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, +} from 'class-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() + @IsUUID() + roomTypeId!: string; + + @ApiProperty() + @IsUUID() + ratePlanId!: string; + + @ApiProperty({ example: '2026-10-01' }) + @IsDateString() + checkIn!: string; + + @ApiProperty({ example: '2026-10-03' }) + @IsDateString() + 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() + @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; +} From 7c1c024f113a55ee60d52f0a86859587daf441e7 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 17:12:14 +0200 Subject: [PATCH 13/87] fix(booking-requests): harden public submission --- .../booking-request-date.validator.ts | 72 ++++ .../booking-request-public.controller.ts | 1 + .../booking-request-submission.spec.ts | 267 ++++++++++++++- .../booking-request.service.ts | 316 +++++++++++++++--- .../dto/submit-booking-request.dto.ts | 20 +- .../saved-payment-method-gateway.interface.ts | 16 +- .../mock-saved-payment-method.gateway.spec.ts | 47 ++- .../mock-saved-payment-method.gateway.ts | 66 +++- ...tripe-saved-payment-method.gateway.spec.ts | 65 +++- .../stripe-saved-payment-method.gateway.ts | 31 +- ...nsupported-saved-payment-method.gateway.ts | 7 +- .../src/booking-request-schema.spec.ts | 8 + .../src/migrations/0021_booking_requests.sql | 7 + packages/database/src/push-schema.ts | 5 + .../database/src/schema/booking-request.ts | 8 + 15 files changed, 844 insertions(+), 92 deletions(-) create mode 100644 apps/api/src/modules/booking-request/booking-request-date.validator.ts 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-public.controller.ts b/apps/api/src/modules/booking-request/booking-request-public.controller.ts index 08853348..169b731f 100644 --- a/apps/api/src/modules/booking-request/booking-request-public.controller.ts +++ b/apps/api/src/modules/booking-request/booking-request-public.controller.ts @@ -26,6 +26,7 @@ export class BookingRequestPublicController { ) {} @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) { 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 index d121f929..4c5c7c06 100644 --- a/apps/api/src/modules/booking-request/booking-request-submission.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-submission.spec.ts @@ -3,8 +3,12 @@ import { ConflictException, ForbiddenException, } from '@nestjs/common'; -import { bookingRequests } from '@telivityhaip/database'; +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { bookingEngineConfig, bookingRequests } from '@telivityhaip/database'; 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'; @@ -32,6 +36,7 @@ const publicConfig = { bookingMode: 'request' as const, paymentMethodCollection: 'disabled' as const, stripePublishableKey: 'pk_test_public', + depositPolicy: { type: 'none' as const, refundable: true }, sellableRoomTypeIds: [ROOM_TYPE_ID], sellableRatePlanIds: [RATE_PLAN_ID], formQuestions: [formQuestion], @@ -64,7 +69,8 @@ const quote = { }, }; -const submitDto: SubmitBookingRequestDto = { +const submitDto = { + idempotencyKey: 'widget-attempt-1', roomTypeId: ROOM_TYPE_ID, ratePlanId: RATE_PLAN_ID, checkIn: '2026-10-01', @@ -78,16 +84,58 @@ const submitDto: SubmitBookingRequestDto = { specialRequests: 'A quiet room, please.', serviceIds: [], applicationAnswers: { [QUESTION_ID]: 'Leisure' }, -}; +} as SubmitBookingRequestDto; function makeHarness() { let insertedValues: Record | undefined; - const returning = vi.fn().mockResolvedValue([{ id: REQUEST_ID }]); + const storedRequests: Array<{ + id: string; + propertyId: string; + submissionIdempotencyKey: string; + submissionFingerprint: string; + setupIntentId: string | null; + }> = []; + 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; - return { returning }; + pendingValues = input; + return { + returning, + onConflictDoNothing: vi.fn(() => ({ returning })), + }; }); - const db = { + const db: Record = { insert: vi.fn((table: unknown) => { if (table !== bookingRequests) { throw new Error('Submission attempted a non-request database write'); @@ -95,6 +143,35 @@ function makeHarness() { return { values }; }), }; + 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 () => table === bookingEngineConfig ? [lockedConfig] : []), + then: (resolve, reject) => Promise.resolve( + table === bookingRequests ? storedRequests : [], + ).then(resolve, reject), + }; + return chain; + }); + 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; + try { + return await callback(db); + } finally { + release(); + } + }); const config = { getPublicConfig: vi.fn().mockResolvedValue(structuredClone(publicConfig)), }; @@ -145,6 +222,8 @@ function makeHarness() { savedPaymentMethod, webhook, values, + lockedConfig, + storedRequests, get insertedValues() { return insertedValues; }, @@ -164,6 +243,76 @@ describe('BookingRequestPublicController validation contract', () => { '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); + }); }); describe('BookingRequestService public card setup', () => { @@ -208,6 +357,7 @@ describe('BookingRequestService public card setup', () => { expect(harness.savedPaymentMethod.createSetup).toHaveBeenCalledWith( 'ada@example.com', `booking-request:${PROPERTY_ID}:widget-attempt-1`, + { propertyId: PROPERTY_ID, applicationId: 'widget-attempt-1' }, ); }); }); @@ -283,6 +433,16 @@ describe('BookingRequestService.submit', () => { 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), @@ -305,6 +465,7 @@ describe('BookingRequestService.submit', () => { ...structuredClone(publicConfig), paymentMethodCollection: 'optional', }); + harness.lockedConfig.paymentMethodCollection = 'optional'; await expect(harness.service.submit(PROPERTY_ID, submitDto)).resolves.toEqual({ requestId: REQUEST_ID, @@ -330,6 +491,7 @@ describe('BookingRequestService.submit', () => { ...structuredClone(publicConfig), paymentMethodCollection, }); + harness.lockedConfig.paymentMethodCollection = paymentMethodCollection; const dto = { ...submitDto, setupIntentId: 'seti_client_reference_only', @@ -342,8 +504,10 @@ describe('BookingRequestService.submit', () => { 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', @@ -473,4 +637,95 @@ describe('BookingRequestService.submit', () => { expect(JSON.stringify(harness.webhook.emit.mock.calls)).not.toContain('consent'); expect(JSON.stringify(harness.webhook.emit.mock.calls)).not.toContain('seti_'); }); + + 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.emit).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.emit).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.emit).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.webhook.emit).toHaveBeenCalledOnce(); + }); + + it('acknowledges the durable request when its post-commit event/audit consequence fails', async () => { + harness.webhook.emit.mockRejectedValueOnce(new Error('audit 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.', + }); + 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.webhook.emit).toHaveBeenCalledOnce(); + }); + + 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.emit).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.emit).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/modules/booking-request/booking-request.service.ts b/apps/api/src/modules/booking-request/booking-request.service.ts index a68079a3..eff67a03 100644 --- a/apps/api/src/modules/booking-request/booking-request.service.ts +++ b/apps/api/src/modules/booking-request/booking-request.service.ts @@ -4,15 +4,24 @@ import { ForbiddenException, Inject, Injectable, - InternalServerErrorException, + Logger, } from '@nestjs/common'; -import { bookingRequests } from '@telivityhaip/database'; -import type { PaymentMethodCollection } from '@telivityhaip/database'; +import { bookingEngineConfig, bookingRequests } from '@telivityhaip/database'; +import type { + BookingFormQuestion, + PaymentMethodCollection, +} from '@telivityhaip/database'; import type { WebhookEvent } from '@telivityhaip/shared'; +import { createHash } from 'node:crypto'; +import { and, eq } from 'drizzle-orm'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { DRIZZLE } from '../../database/database.module'; import { BookingEngineConfigService } from '../booking-engine/booking-engine-config.service'; import { BookingEngineService } from '../booking-engine/booking-engine.service'; -import { validateApplicationAnswers } from '../booking-engine/booking-form-questions'; +import { + validateApplicationAnswers, + validateQuestionDefinitions, +} from '../booking-engine/booking-form-questions'; import { SAVED_PAYMENT_METHOD_GATEWAY, type SavedPaymentMethod, @@ -21,6 +30,7 @@ import { import { RatePlanService } from '../rate-plan/rate-plan.service'; import { AvailabilityService } from '../reservation/availability.service'; import { WebhookService } from '../webhook/webhook.service'; +import { assertCanonicalStayDates } from './booking-request-date.validator'; import type { CreateRequestCardSetupDto } from './dto/create-request-card-setup.dto'; import type { SubmitBookingRequestDto } from './dto/submit-booking-request.dto'; @@ -35,6 +45,7 @@ type PublicRequestConfig = Awaited< >; type CardSnapshot = { + setupIntentId: string | null; stripeCustomerId: string | null; stripePaymentMethodId: string | null; cardLastFour: string | null; @@ -44,19 +55,24 @@ type CardSnapshot = { consentedAt: Date | null; }; -type BookingRequestDatabase = { - insert(table: typeof bookingRequests): { - values(input: typeof bookingRequests.$inferInsert): { - returning(selection: { id: typeof bookingRequests.id }): Promise>; - }; - }; +type BookingRequestDatabase = PostgresJsDatabase; + +type ExistingRequest = { + id: string; + propertyId: string; + submissionIdempotencyKey: string; + submissionFingerprint: string; }; +type LockedRequestConfig = typeof bookingEngineConfig.$inferSelect; + const ACKNOWLEDGEMENT_MESSAGE = 'Your booking request has been received and is pending review.'; @Injectable() export class BookingRequestService { + private readonly logger = new Logger(BookingRequestService.name); + constructor( @Inject(DRIZZLE) private readonly db: BookingRequestDatabase, @Inject(BookingEngineConfigService) @@ -85,9 +101,11 @@ export class BookingRequestService { throw new BadRequestException('Payment method collection is unavailable for this property'); } + const applicationId = this.normalizeApplicationId(dto.idempotencyKey); const setup = await this.savedPaymentMethodGateway.createSetup( dto.guestEmail, - `booking-request:${propertyId}:${dto.idempotencyKey}`, + `booking-request:${propertyId}:${applicationId}`, + { propertyId, applicationId }, ); return { setupIntentId: setup.setupIntentId, @@ -99,6 +117,16 @@ export class BookingRequestService { 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) return this.acknowledgeReplay(existing, fingerprint); + const config = await this.configService.getPublicConfig(propertyId); this.assertRequestMode(config); this.assertConfiguredOffer(config, dto.roomTypeId, dto.ratePlanId); @@ -126,52 +154,78 @@ export class BookingRequestService { children: dto.children, serviceIds: dto.serviceIds, }); - const card = await this.resolveCard(config.paymentMethodCollection, dto); + this.assertQuoteUsesConfigSnapshot(config, quote); + const card = await this.resolveCard( + config.paymentMethodCollection, + dto, + { propertyId, applicationId }, + ); - const [request] = await this.db - .insert(bookingRequests) - .values({ + 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, - 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), - currentQuoteSnapshot: null, - currencyCode: quote.currencyCode, - ...card, - }) - .returning({ id: bookingRequests.id }); + applicationId, + ); + if (transactionReplay) { + this.acknowledgeReplay(transactionReplay, fingerprint); + return { requestId: transactionReplay.id, created: false }; + } - if (!request) { - throw new InternalServerErrorException('Booking request could not be created'); - } + 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), + currentQuoteSnapshot: null, + currencyCode: quote.currencyCode, + ...card, + }) + .onConflictDoNothing() + .returning({ id: bookingRequests.id }); - await this.webhookService.emit( - // Request webhook types are completed with the staff lifecycle events. - 'booking_request.created' as unknown as WebhookEvent, - 'booking_request', - request.id, - { requestId: request.id, status: 'pending' }, - propertyId, - ); + if (request) return { requestId: request.id, created: true }; - return { - requestId: request.id, - status: 'pending', - message: ACKNOWLEDGEMENT_MESSAGE, - }; + 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, created: false }; + }); + + if (result.created) await this.emitCreatedBestEffort(result.requestId, propertyId); + return this.acknowledgement(result.requestId); } private assertRequestMode(config: PublicRequestConfig): void { @@ -236,6 +290,7 @@ export class BookingRequestService { private async resolveCard( policy: PaymentMethodCollection, dto: SubmitBookingRequestDto, + provenance: { propertyId: string; applicationId: string }, ): Promise { if (policy === 'disabled' || !dto.setupIntentId) { return this.emptyCardSnapshot(); @@ -243,12 +298,16 @@ export class BookingRequestService { let savedMethod: SavedPaymentMethod; try { - savedMethod = await this.savedPaymentMethodGateway.resolveSetup(dto.setupIntentId); + 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, @@ -261,6 +320,7 @@ export class BookingRequestService { private emptyCardSnapshot(): CardSnapshot { return { + setupIntentId: null, stripeCustomerId: null, stripePaymentMethodId: null, cardLastFour: null, @@ -306,4 +366,154 @@ export class BookingRequestService { } 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 { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) ?? 'undefined'; + } + if (Array.isArray(value)) { + return `[${value.map((item) => this.stableSerialize(item)).join(',')}]`; + } + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${this.stableSerialize(record[key])}`) + .join(',')}}`; + } + + 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 async emitCreatedBestEffort(requestId: string, propertyId: string): Promise { + try { + await this.webhookService.emit( + // Request webhook types are completed with the staff lifecycle events. + 'booking_request.created' as unknown as WebhookEvent, + 'booking_request', + requestId, + { requestId, status: 'pending' }, + propertyId, + ); + } catch (error: unknown) { + this.logger.error( + `Booking request ${requestId} was committed but its created consequence failed`, + error instanceof Error ? error.stack : undefined, + ); + } + } + + 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 ?? []) as BookingFormQuestion[], + ) + .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/submit-booking-request.dto.ts b/apps/api/src/modules/booking-request/dto/submit-booking-request.dto.ts index 9a9ec8d4..55cbb267 100644 --- 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 @@ -2,7 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsArray, IsBoolean, - IsDateString, IsEmail, IsInt, IsObject, @@ -11,7 +10,12 @@ import { 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 @@ -19,6 +23,15 @@ import { * 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; @@ -28,11 +41,12 @@ export class SubmitBookingRequestDto { ratePlanId!: string; @ApiProperty({ example: '2026-10-01' }) - @IsDateString() + @IsCanonicalCalendarDate() checkIn!: string; @ApiProperty({ example: '2026-10-03' }) - @IsDateString() + @IsCanonicalCalendarDate() + @IsAfterCheckIn() checkOut!: string; @ApiProperty({ example: 'Ada' }) 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 index ea845cc4..be15114d 100644 --- 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 @@ -6,6 +6,11 @@ export type SavedPaymentMethod = { cardBrand: string; }; +export type SavedPaymentMethodProvenance = { + propertyId: string; + applicationId: string; +}; + export type SavedPaymentMethodChargeInput = { customerId: string; paymentMethodId: string; @@ -22,12 +27,19 @@ export type SavedPaymentMethodChargeResult = { }; export interface SavedPaymentMethodGateway { - createSetup(email: string, idempotencyKey: string): Promise<{ + createSetup( + email: string, + idempotencyKey: string, + provenance: SavedPaymentMethodProvenance, + ): Promise<{ setupIntentId: string; clientSecret: string; customerId: string; }>; - resolveSetup(setupIntentId: string): Promise; + resolveSetup( + setupIntentId: string, + expectedProvenance: SavedPaymentMethodProvenance, + ): Promise; charge(input: SavedPaymentMethodChargeInput): Promise; } 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 index f53001b7..e4c6b283 100644 --- 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 @@ -9,14 +9,27 @@ 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'); - const retry = await gateway.createSetup('guest@example.com', 'request-card:req_123'); + 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)).resolves.toEqual({ + await expect(gateway.resolveSetup(first.setupIntentId, provenance)).resolves.toEqual({ setupIntentId: first.setupIntentId, customerId: first.customerId, paymentMethodId: expect.stringMatching(/^pm_mock_/), @@ -28,11 +41,32 @@ describe('MockSavedPaymentMethodGateway', () => { it('does not resolve a setup identifier it did not create', async () => { const gateway = new MockSavedPaymentMethodGateway(); - await expect(gateway.resolveSetup('seti_from_the_browser')).rejects.toThrow( + 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 = { @@ -140,10 +174,11 @@ describe('PaymentModule saved-payment-method registration', () => { } as ConfigService; const gateway = provider.useFactory(alternativeConfig); - await expect(gateway.createSetup('guest@example.com', 'setup-key')).rejects.toThrow( + 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')).rejects.toThrow( + await expect(gateway.resolveSetup('seti_test', provenance)).rejects.toThrow( /Saved payment methods are not supported.*adyen/, ); await expect(gateway.charge({ 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 index 01af7ebc..af5b4750 100644 --- a/apps/api/src/modules/payment/mock-saved-payment-method.gateway.ts +++ b/apps/api/src/modules/payment/mock-saved-payment-method.gateway.ts @@ -5,24 +5,36 @@ import type { SavedPaymentMethodChargeInput, SavedPaymentMethodChargeResult, SavedPaymentMethodGateway, + SavedPaymentMethodProvenance, } from './interfaces/saved-payment-method-gateway.interface'; +type MockSetupRecord = { + setup: { setupIntentId: string; clientSecret: string; customerId: string }; + paymentMethod: SavedPaymentMethod; + propertyId: string; + applicationHash: string; +}; + @Injectable() export class MockSavedPaymentMethodGateway implements SavedPaymentMethodGateway { - private readonly setupsByKey = new Map(); - private readonly paymentMethodsBySetupId = new Map(); + private readonly setupsByKey = new Map(); + private readonly setupsBySetupId = new Map(); private readonly chargesByKey = new Map(); - async createSetup(_email: string, idempotencyKey: string): Promise<{ + async createSetup( + _email: string, + idempotencyKey: string, + provenance: SavedPaymentMethodProvenance, + ): Promise<{ setupIntentId: string; clientSecret: string; customerId: string; }> { const existing = this.setupsByKey.get(idempotencyKey); - if (existing) return existing.setup; + if (existing) { + this.assertProvenance(existing, provenance); + return existing.setup; + } const suffix = this.stableSuffix(idempotencyKey); const setup = { @@ -37,17 +49,27 @@ export class MockSavedPaymentMethodGateway implements SavedPaymentMethodGateway cardLastFour: '4242', cardBrand: 'visa', }; - this.setupsByKey.set(idempotencyKey, { setup, paymentMethod }); - this.paymentMethodsBySetupId.set(setup.setupIntentId, paymentMethod); + 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): Promise { - const paymentMethod = this.paymentMethodsBySetupId.get(setupIntentId); - if (!paymentMethod) { + async resolveSetup( + setupIntentId: string, + expectedProvenance: SavedPaymentMethodProvenance, + ): Promise { + const record = this.setupsBySetupId.get(setupIntentId); + if (!record) { throw new Error(`Unknown mock SetupIntent '${setupIntentId}'`); } - return paymentMethod; + this.assertProvenance(record, expectedProvenance); + return record.paymentMethod; } async charge(input: SavedPaymentMethodChargeInput): Promise { @@ -64,6 +86,22 @@ export class MockSavedPaymentMethodGateway implements SavedPaymentMethodGateway } private stableSuffix(value: string): string { - return createHash('sha256').update(value).digest('hex').slice(0, 24); + 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/stripe-saved-payment-method.gateway.spec.ts b/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.spec.ts index 550621d0..a32b0a0f 100644 --- 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 @@ -26,6 +26,11 @@ function config(secretKey = 'sk_test_saved_method'): ConfigService { } describe('StripeSavedPaymentMethodGateway', () => { + const provenance = { + propertyId: 'aaaaaaaa-0000-4000-a000-000000000001', + applicationId: 'submission-attempt-1', + }; + let gateway: StripeSavedPaymentMethodGateway; let stripe: { customers: { create: ReturnType }; @@ -57,7 +62,11 @@ describe('StripeSavedPaymentMethodGateway', () => { }); await expect( - gateway.createSetup('guest@example.com', 'request-card:req_123'), + gateway.createSetup( + 'guest@example.com', + 'request-card:req_123', + provenance, + ), ).resolves.toEqual({ setupIntentId: 'seti_trusted', clientSecret: 'seti_secret_safe_for_guest', @@ -72,6 +81,11 @@ describe('StripeSavedPaymentMethodGateway', () => { customer: 'cus_trusted', usage: 'off_session', payment_method_types: ['card'], + metadata: { + haip_property_id: provenance.propertyId, + haip_application_hash: + 'cf18a22e39cd5bba19be060f31c6a9e68094cefbaf2c4a23c5738bf78c687a3a', + }, }, { idempotencyKey: 'request-card:req_123' }, ); @@ -85,7 +99,7 @@ describe('StripeSavedPaymentMethodGateway', () => { payment_method: 'pm_untrusted', }); - await expect(gateway.resolveSetup('seti_unconfirmed')).rejects.toThrow( + await expect(gateway.resolveSetup('seti_unconfirmed', provenance)).rejects.toThrow( /has not succeeded/, ); expect(stripe.paymentMethods.retrieve).not.toHaveBeenCalled(); @@ -97,6 +111,11 @@ describe('StripeSavedPaymentMethodGateway', () => { 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', @@ -111,7 +130,7 @@ describe('StripeSavedPaymentMethodGateway', () => { }, }); - const result = await gateway.resolveSetup('seti_trusted'); + const result = await gateway.resolveSetup('seti_trusted', provenance); expect(stripe.setupIntents.retrieve).toHaveBeenCalledWith('seti_trusted'); expect(stripe.paymentMethods.retrieve).toHaveBeenCalledWith('pm_trusted'); @@ -132,6 +151,11 @@ describe('StripeSavedPaymentMethodGateway', () => { 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', @@ -140,7 +164,33 @@ describe('StripeSavedPaymentMethodGateway', () => { card: null, }); - await expect(gateway.resolveSetup('seti_bank')).rejects.toThrow(/card payment method/); + 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 () => { @@ -149,6 +199,11 @@ describe('StripeSavedPaymentMethodGateway', () => { 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', @@ -160,7 +215,7 @@ describe('StripeSavedPaymentMethodGateway', () => { }, }); - await expect(gateway.resolveSetup('seti_mismatch')).rejects.toThrow( + await expect(gateway.resolveSetup('seti_mismatch', provenance)).rejects.toThrow( /PaymentMethod.*does not belong.*cus_setup_owner/, ); }); 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 index 60039edc..8d175693 100644 --- a/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts +++ b/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts @@ -1,5 +1,6 @@ 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 { @@ -7,8 +8,12 @@ import type { 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'; + @Injectable() export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGateway { private readonly stripe: Stripe; @@ -28,7 +33,11 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa }); } - async createSetup(email: string, idempotencyKey: string): Promise<{ + async createSetup( + email: string, + idempotencyKey: string, + provenance: SavedPaymentMethodProvenance, + ): Promise<{ setupIntentId: string; clientSecret: string; customerId: string; @@ -40,6 +49,10 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa customer: customer.id, usage: 'off_session', payment_method_types: ['card'], + metadata: { + [PROPERTY_METADATA_KEY]: provenance.propertyId, + [APPLICATION_METADATA_KEY]: this.applicationHash(provenance.applicationId), + }, }, options, ); @@ -55,11 +68,21 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa }; } - async resolveSetup(setupIntentId: string): Promise { + 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); @@ -128,6 +151,10 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa 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 & { 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 index ba4e4ca4..68230856 100644 --- a/apps/api/src/modules/payment/unsupported-saved-payment-method.gateway.ts +++ b/apps/api/src/modules/payment/unsupported-saved-payment-method.gateway.ts @@ -3,6 +3,7 @@ import type { SavedPaymentMethodChargeInput, SavedPaymentMethodChargeResult, SavedPaymentMethodGateway, + SavedPaymentMethodProvenance, } from './interfaces/saved-payment-method-gateway.interface'; import type { PaymentGatewayProvider } from './payment-gateway.factory'; @@ -12,11 +13,15 @@ export class UnsupportedSavedPaymentMethodGateway implements SavedPaymentMethodG async createSetup( _email: string, _idempotencyKey: string, + _provenance: SavedPaymentMethodProvenance, ): Promise<{ setupIntentId: string; clientSecret: string; customerId: string }> { throw this.unsupported(); } - async resolveSetup(_setupIntentId: string): Promise { + async resolveSetup( + _setupIntentId: string, + _expectedProvenance: SavedPaymentMethodProvenance, + ): Promise { throw this.unsupported(); } diff --git a/packages/database/src/booking-request-schema.spec.ts b/packages/database/src/booking-request-schema.spec.ts index 9cb58fad..7ddb2245 100644 --- a/packages/database/src/booking-request-schema.spec.ts +++ b/packages/database/src/booking-request-schema.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { getTableConfig } from 'drizzle-orm/pg-core'; import { bookingEngineConfig, bookingRequests, @@ -10,10 +11,17 @@ describe('booking request schema', () => { it('exports request persistence and backward-compatible config columns', () => { expect(bookingRequests.propertyId).toBeDefined(); expect(bookingRequests.submittedQuoteSnapshot).toBeDefined(); + expect(bookingRequests.submissionIdempotencyKey).toBeDefined(); + expect(bookingRequests.submissionFingerprint).toBeDefined(); + expect(bookingRequests.setupIntentId).toBeDefined(); expect(bookingRequestInstallments.dueMilestone).toBeDefined(); expect(bookingEngineConfig.bookingMode).toBeDefined(); expect(bookingEngineConfig.paymentMethodCollection).toBeDefined(); expect(payments.bookingRequestId).toBeDefined(); expect(payments.idempotencyKey).toBeDefined(); + + const indexNames = getTableConfig(bookingRequests).indexes.map((index) => index.config.name); + expect(indexNames).toContain('booking_requests_property_submission_key_unique'); + expect(indexNames).toContain('booking_requests_setup_intent_unique'); }); }); diff --git a/packages/database/src/migrations/0021_booking_requests.sql b/packages/database/src/migrations/0021_booking_requests.sql index 26445223..0a36e0a2 100644 --- a/packages/database/src/migrations/0021_booking_requests.sql +++ b/packages/database/src/migrations/0021_booking_requests.sql @@ -47,6 +47,8 @@ ALTER TABLE booking_engine_config ALTER COLUMN form_questions SET NOT NULL; CREATE TABLE IF NOT EXISTS booking_requests ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), property_id uuid NOT NULL REFERENCES properties(id), + submission_idempotency_key varchar(200) NOT NULL, + submission_fingerprint varchar(64) NOT NULL, status booking_request_status NOT NULL DEFAULT 'pending', arrival_date date NOT NULL, departure_date date NOT NULL, @@ -65,6 +67,7 @@ CREATE TABLE IF NOT EXISTS booking_requests ( submitted_quote_snapshot jsonb NOT NULL, current_quote_snapshot jsonb, currency_code varchar(3) NOT NULL, + setup_intent_id varchar(255), stripe_customer_id varchar(255), stripe_payment_method_id varchar(255), card_last_four varchar(4), @@ -86,6 +89,10 @@ CREATE TABLE IF NOT EXISTS booking_requests ( CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_accepted_reservation_unique ON booking_requests (accepted_reservation_id); +CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_property_submission_key_unique + ON booking_requests (property_id, submission_idempotency_key); +CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_setup_intent_unique + ON booking_requests (setup_intent_id); CREATE INDEX IF NOT EXISTS booking_requests_property_status_idx ON booking_requests (property_id, status); diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index 06835677..2a0b96b4 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -1132,6 +1132,8 @@ async function main() { `CREATE TABLE IF NOT EXISTS booking_requests ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), property_id uuid NOT NULL REFERENCES properties(id), + submission_idempotency_key varchar(200) NOT NULL, + submission_fingerprint varchar(64) NOT NULL, status booking_request_status NOT NULL DEFAULT 'pending', arrival_date date NOT NULL, departure_date date NOT NULL, @@ -1150,6 +1152,7 @@ async function main() { submitted_quote_snapshot jsonb NOT NULL, current_quote_snapshot jsonb, currency_code varchar(3) NOT NULL, + setup_intent_id varchar(255), stripe_customer_id varchar(255), stripe_payment_method_id varchar(255), card_last_four varchar(4), @@ -1169,6 +1172,8 @@ async function main() { updated_at timestamptz NOT NULL DEFAULT now() )`, `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_accepted_reservation_unique ON booking_requests (accepted_reservation_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_property_submission_key_unique ON booking_requests (property_id, submission_idempotency_key)`, + `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_setup_intent_unique ON booking_requests (setup_intent_id)`, `CREATE INDEX IF NOT EXISTS booking_requests_property_status_idx ON booking_requests (property_id, status)`, `CREATE TABLE IF NOT EXISTS booking_request_installments ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/packages/database/src/schema/booking-request.ts b/packages/database/src/schema/booking-request.ts index 6237cc02..f895e9d3 100644 --- a/packages/database/src/schema/booking-request.ts +++ b/packages/database/src/schema/booking-request.ts @@ -67,6 +67,8 @@ export const bookingRequestEmailDeliveryStatusEnum = pgEnum('booking_request_ema export const bookingRequests = pgTable('booking_requests', { id: uuid('id').primaryKey().defaultRandom(), propertyId: uuid('property_id').notNull().references(() => properties.id), + submissionIdempotencyKey: varchar('submission_idempotency_key', { length: 200 }).notNull(), + submissionFingerprint: varchar('submission_fingerprint', { length: 64 }).notNull(), status: bookingRequestStatusEnum('status').notNull().default('pending'), arrivalDate: date('arrival_date').notNull(), departureDate: date('departure_date').notNull(), @@ -85,6 +87,7 @@ export const bookingRequests = pgTable('booking_requests', { submittedQuoteSnapshot: jsonb('submitted_quote_snapshot').notNull(), currentQuoteSnapshot: jsonb('current_quote_snapshot'), currencyCode: varchar('currency_code', { length: 3 }).notNull(), + setupIntentId: varchar('setup_intent_id', { length: 255 }), stripeCustomerId: varchar('stripe_customer_id', { length: 255 }), stripePaymentMethodId: varchar('stripe_payment_method_id', { length: 255 }), cardLastFour: varchar('card_last_four', { length: 4 }), @@ -103,6 +106,11 @@ export const bookingRequests = pgTable('booking_requests', { createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ + propertySubmissionKeyUnique: + uniqueIndex('booking_requests_property_submission_key_unique') + .on(table.propertyId, table.submissionIdempotencyKey), + setupIntentUnique: uniqueIndex('booking_requests_setup_intent_unique') + .on(table.setupIntentId), acceptedReservationUnique: uniqueIndex('booking_requests_accepted_reservation_unique') .on(table.acceptedReservationId), })); From bc2b5732e3611805257888e13172db691db17864 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 17:39:55 +0200 Subject: [PATCH 14/87] fix(booking-requests): persist created consequence --- .../booking-request-submission.spec.ts | 203 +++++++++++++++--- .../booking-request.service.ts | 183 ++++++++++++++-- .../modules/webhook/webhook.service.spec.ts | 32 +++ .../src/modules/webhook/webhook.service.ts | 9 + .../src/booking-request-schema.spec.ts | 16 ++ .../src/migrations/0021_booking_requests.sql | 40 ++++ packages/database/src/push-schema.ts | 26 ++- .../database/src/schema/booking-request.ts | 31 +++ packages/database/src/schema/index.ts | 3 + 9 files changed, 493 insertions(+), 50 deletions(-) create mode 100644 apps/api/src/modules/webhook/webhook.service.spec.ts 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 index 4c5c7c06..70551473 100644 --- a/apps/api/src/modules/booking-request/booking-request-submission.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-submission.spec.ts @@ -6,7 +6,12 @@ import { import { GUARDS_METADATA } from '@nestjs/common/constants'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; -import { bookingEngineConfig, bookingRequests } from '@telivityhaip/database'; +import { + auditLogs, + bookingEngineConfig, + bookingRequestConsequences, + bookingRequests, +} from '@telivityhaip/database'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BookingThrottleGuard } from '../booking-engine/booking-throttle.guard'; import { BookingRequestPublicController } from './booking-request-public.controller'; @@ -16,6 +21,7 @@ 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'; @@ -95,6 +101,20 @@ function makeHarness() { 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', @@ -135,12 +155,37 @@ function makeHarness() { 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) { - throw new Error('Submission attempted a non-request database write'); - } - return { values }; + 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(() => { @@ -151,13 +196,42 @@ function makeHarness() { return chain; }), where: vi.fn(() => chain), - for: vi.fn(async () => table === bookingEngineConfig ? [lockedConfig] : []), + for: vi.fn(async () => { + if (table === bookingEngineConfig) return [lockedConfig]; + if (table === bookingRequestConsequences) return storedConsequences; + return []; + }), then: (resolve, reject) => Promise.resolve( - table === bookingRequests ? storedRequests : [], + 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; @@ -201,7 +275,10 @@ function makeHarness() { cardBrand: 'visa', }), }; - const webhook = { emit: vi.fn().mockResolvedValue(undefined) }; + const webhook = { + emit: vi.fn().mockResolvedValue(undefined), + dispatchPersisted: vi.fn().mockResolvedValue(undefined), + }; const service = new BookingRequestService( db as unknown as ConstructorParameters[0], config as unknown as ConstructorParameters[1], @@ -222,8 +299,12 @@ function makeHarness() { savedPaymentMethod, webhook, values, + consequenceValues, + auditValues, lockedConfig, storedRequests, + storedConsequences, + storedAudits, get insertedValues() { return insertedValues; }, @@ -578,7 +659,7 @@ describe('BookingRequestService.submit', () => { children: 1, serviceIds: [], }); - expect(harness.db.insert).toHaveBeenCalledOnce(); + expect(harness.db.insert).toHaveBeenCalledTimes(3); expect(harness.values).toHaveBeenCalledOnce(); expect(harness.insertedValues).toMatchObject({ propertyId: PROPERTY_ID, @@ -620,22 +701,42 @@ describe('BookingRequestService.submit', () => { expect(Object.keys(acknowledgement).sort()).toEqual(['message', 'requestId', 'status']); }); - it('emits a sanitized created event after the request write', async () => { + it('commits a durable audit/outbox and dispatches its sanitized created event', async () => { await harness.service.submit(PROPERTY_ID, submitDto); - expect(harness.webhook.emit).toHaveBeenCalledWith( - 'booking_request.created', - 'booking_request', - REQUEST_ID, - { requestId: REQUEST_ID, status: 'pending' }, - PROPERTY_ID, - ); + 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); expect(harness.values.mock.invocationCallOrder[0]).toBeLessThan( - harness.webhook.emit.mock.invocationCallOrder[0]!, + harness.webhook.dispatchPersisted.mock.invocationCallOrder[0]!, ); - expect(JSON.stringify(harness.webhook.emit.mock.calls)).not.toContain('Leisure'); - expect(JSON.stringify(harness.webhook.emit.mock.calls)).not.toContain('consent'); - expect(JSON.stringify(harness.webhook.emit.mock.calls)).not.toContain('seti_'); + 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_'); }); it('returns the existing acknowledgement for an exact replay without repeating work', async () => { @@ -645,7 +746,7 @@ describe('BookingRequestService.submit', () => { expect(replay).toEqual(first); expect(harness.values).toHaveBeenCalledOnce(); expect(harness.bookingEngine.quote).toHaveBeenCalledOnce(); - expect(harness.webhook.emit).toHaveBeenCalledOnce(); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledOnce(); }); it('conflicts when a replay key is reused for a different submission payload', async () => { @@ -656,7 +757,7 @@ describe('BookingRequestService.submit', () => { guestLastName: 'Byron', })).rejects.toBeInstanceOf(ConflictException); expect(harness.values).toHaveBeenCalledOnce(); - expect(harness.webhook.emit).toHaveBeenCalledOnce(); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledOnce(); }); it('rejects reuse of one trusted SetupIntent under another application key', async () => { @@ -678,7 +779,7 @@ describe('BookingRequestService.submit', () => { ...withCard, idempotencyKey: 'widget-attempt-2', })).rejects.toBeInstanceOf(ConflictException); - expect(harness.webhook.emit).toHaveBeenCalledOnce(); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledOnce(); }); it('collapses concurrent exact replays into one request and one created event', async () => { @@ -689,24 +790,66 @@ describe('BookingRequestService.submit', () => { expect(replay).toEqual(first); expect(harness.values).toHaveBeenCalledOnce(); - expect(harness.webhook.emit).toHaveBeenCalledOnce(); + expect(harness.storedConsequences).toHaveLength(1); + expect(harness.storedAudits).toHaveLength(1); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledOnce(); }); - it('acknowledges the durable request when its post-commit event/audit consequence fails', async () => { - harness.webhook.emit.mockRejectedValueOnce(new Error('audit unavailable')); + 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.webhook.emit).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 () => { @@ -716,7 +859,7 @@ describe('BookingRequestService.submit', () => { ConflictException, ); expect(harness.values).not.toHaveBeenCalled(); - expect(harness.webhook.emit).not.toHaveBeenCalled(); + expect(harness.webhook.dispatchPersisted).not.toHaveBeenCalled(); }); it('does not commit a card-policy snapshot that changed during submission', async () => { @@ -726,6 +869,6 @@ describe('BookingRequestService.submit', () => { ConflictException, ); expect(harness.values).not.toHaveBeenCalled(); - expect(harness.webhook.emit).not.toHaveBeenCalled(); + expect(harness.webhook.dispatchPersisted).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/modules/booking-request/booking-request.service.ts b/apps/api/src/modules/booking-request/booking-request.service.ts index eff67a03..ba1a3845 100644 --- a/apps/api/src/modules/booking-request/booking-request.service.ts +++ b/apps/api/src/modules/booking-request/booking-request.service.ts @@ -6,12 +6,16 @@ import { Injectable, Logger, } from '@nestjs/common'; -import { bookingEngineConfig, bookingRequests } from '@telivityhaip/database'; +import { + auditLogs, + bookingEngineConfig, + bookingRequestConsequences, + bookingRequests, +} from '@telivityhaip/database'; import type { BookingFormQuestion, PaymentMethodCollection, } from '@telivityhaip/database'; -import type { WebhookEvent } from '@telivityhaip/shared'; import { createHash } from 'node:crypto'; import { and, eq } from 'drizzle-orm'; import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; @@ -29,7 +33,10 @@ import { } from '../payment/interfaces/saved-payment-method-gateway.interface'; import { RatePlanService } from '../rate-plan/rate-plan.service'; import { AvailabilityService } from '../reservation/availability.service'; -import { WebhookService } from '../webhook/webhook.service'; +import { + WebhookService, + type WebhookPayload, +} from '../webhook/webhook.service'; import { assertCanonicalStayDates } from './booking-request-date.validator'; import type { CreateRequestCardSetupDto } from './dto/create-request-card-setup.dto'; import type { SubmitBookingRequestDto } from './dto/submit-booking-request.dto'; @@ -65,9 +72,12 @@ type ExistingRequest = { }; type LockedRequestConfig = typeof bookingEngineConfig.$inferSelect; +type CreatedConsequence = typeof bookingRequestConsequences.$inferSelect; const ACKNOWLEDGEMENT_MESSAGE = 'Your booking request has been received and is pending review.'; +const CREATED_CONSEQUENCE_KIND = 'created_event' as const; +const CONSEQUENCE_CLAIM_LEASE_MS = 5 * 60 * 1000; @Injectable() export class BookingRequestService { @@ -125,7 +135,11 @@ export class BookingRequestService { propertyId, applicationId, ); - if (existing) return this.acknowledgeReplay(existing, fingerprint); + if (existing) { + const acknowledgement = this.acknowledgeReplay(existing, fingerprint); + await this.deliverCreatedConsequenceBestEffort(existing.id, propertyId); + return acknowledgement; + } const config = await this.configService.getPublicConfig(propertyId); this.assertRequestMode(config); @@ -178,7 +192,7 @@ export class BookingRequestService { ); if (transactionReplay) { this.acknowledgeReplay(transactionReplay, fingerprint); - return { requestId: transactionReplay.id, created: false }; + return { requestId: transactionReplay.id }; } const [request] = await tx @@ -210,7 +224,29 @@ export class BookingRequestService { .onConflictDoNothing() .returning({ id: bookingRequests.id }); - if (request) return { requestId: request.id, created: true }; + 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, + action: 'create', + entityType: 'booking_request', + entityId: request.id, + description: 'Webhook event: booking_request.created', + newValue: structuredClone(createdPayload), + }); + return { requestId: request.id }; + } const concurrent = await this.findExistingRequest( tx, @@ -221,10 +257,10 @@ export class BookingRequestService { throw new ConflictException('Payment method setup has already been used'); } this.acknowledgeReplay(concurrent, fingerprint); - return { requestId: concurrent.id, created: false }; + return { requestId: concurrent.id }; }); - if (result.created) await this.emitCreatedBestEffort(result.requestId, propertyId); + await this.deliverCreatedConsequenceBestEffort(result.requestId, propertyId); return this.acknowledgement(result.requestId); } @@ -456,24 +492,135 @@ export class BookingRequestService { }; } - private async emitCreatedBestEffort(requestId: string, propertyId: string): Promise { + private createdEventPayload( + requestId: string, + propertyId: string, + ): WebhookPayload { + return { + event: 'booking_request.created', + entityType: 'booking_request', + entityId: requestId, + propertyId, + data: { requestId, status: 'pending' }, + timestamp: new Date().toISOString(), + }; + } + + private async deliverCreatedConsequenceBestEffort( + requestId: string, + propertyId: string, + ): Promise { try { - await this.webhookService.emit( - // Request webhook types are completed with the staff lifecycle events. - 'booking_request.created' as unknown as WebhookEvent, - 'booking_request', - requestId, - { requestId, status: 'pending' }, - propertyId, - ); + const consequence = await this.claimCreatedConsequence(requestId, propertyId); + if (!consequence) return; + + try { + await this.webhookService.dispatchPersisted( + consequence.payload as unknown as WebhookPayload, + ); + } catch (error: unknown) { + await this.recordConsequenceFailure(consequence, error); + this.logger.error( + `Booking request ${requestId} was committed but its created consequence failed`, + error instanceof Error ? error.stack : undefined, + ); + return; + } + + 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 its created consequence failed`, + `Booking request ${requestId} was committed but its created consequence state could not be updated`, error instanceof Error ? error.stack : undefined, ); } } + private async claimCreatedConsequence( + requestId: string, + propertyId: 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, CREATED_CONSEQUENCE_KIND), + )) + .for('update'); + const consequence = rows.find((candidate) => + candidate.propertyId === propertyId + && candidate.bookingRequestId === requestId + && candidate.kind === CREATED_CONSEQUENCE_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 }, 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..0591d45e --- /dev/null +++ b/apps/api/src/modules/webhook/webhook.service.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WebhookService, type WebhookPayload } from './webhook.service'; + +describe('WebhookService persisted dispatch', () => { + it('dispatches an already-audited payload without writing another audit row', 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); + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'booking_request.created', + payload, + ); + expect(db.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/webhook/webhook.service.ts b/apps/api/src/modules/webhook/webhook.service.ts index 07c18d35..9c3be987 100644 --- a/apps/api/src/modules/webhook/webhook.service.ts +++ b/apps/api/src/modules/webhook/webhook.service.ts @@ -20,6 +20,15 @@ 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): Promise { + await this.eventEmitter.emitAsync(payload.event, payload); + } + /** * Emit a webhook event and log it to the audit trail. */ diff --git a/packages/database/src/booking-request-schema.spec.ts b/packages/database/src/booking-request-schema.spec.ts index 7ddb2245..62d93f96 100644 --- a/packages/database/src/booking-request-schema.spec.ts +++ b/packages/database/src/booking-request-schema.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { getTableConfig } from 'drizzle-orm/pg-core'; import { bookingEngineConfig, + bookingRequestConsequences, bookingRequests, bookingRequestInstallments, payments, @@ -14,6 +15,15 @@ describe('booking request schema', () => { expect(bookingRequests.submissionIdempotencyKey).toBeDefined(); expect(bookingRequests.submissionFingerprint).toBeDefined(); expect(bookingRequests.setupIntentId).toBeDefined(); + expect(bookingRequestConsequences.propertyId).toBeDefined(); + expect(bookingRequestConsequences.bookingRequestId).toBeDefined(); + expect(bookingRequestConsequences.kind).toBeDefined(); + expect(bookingRequestConsequences.payload).toBeDefined(); + expect(bookingRequestConsequences.status).toBeDefined(); + expect(bookingRequestConsequences.attempts).toBeDefined(); + expect(bookingRequestConsequences.claimedAt).toBeDefined(); + expect(bookingRequestConsequences.lastError).toBeDefined(); + expect(bookingRequestConsequences.completedAt).toBeDefined(); expect(bookingRequestInstallments.dueMilestone).toBeDefined(); expect(bookingEngineConfig.bookingMode).toBeDefined(); expect(bookingEngineConfig.paymentMethodCollection).toBeDefined(); @@ -23,5 +33,11 @@ describe('booking request schema', () => { const indexNames = getTableConfig(bookingRequests).indexes.map((index) => index.config.name); expect(indexNames).toContain('booking_requests_property_submission_key_unique'); expect(indexNames).toContain('booking_requests_setup_intent_unique'); + + const consequenceIndexNames = getTableConfig(bookingRequestConsequences) + .indexes.map((index) => index.config.name); + expect(consequenceIndexNames).toContain( + 'booking_request_consequences_property_request_kind_unique', + ); }); }); diff --git a/packages/database/src/migrations/0021_booking_requests.sql b/packages/database/src/migrations/0021_booking_requests.sql index 0a36e0a2..320970e5 100644 --- a/packages/database/src/migrations/0021_booking_requests.sql +++ b/packages/database/src/migrations/0021_booking_requests.sql @@ -87,6 +87,28 @@ CREATE TABLE IF NOT EXISTS booking_requests ( updated_at timestamptz NOT NULL DEFAULT now() ); +-- Reconcile a local/intermediate copy of the unreleased table before creating +-- replay indexes. Stable legacy placeholders preserve every row without +-- inventing recoverable client payloads. +ALTER TABLE booking_requests + ADD COLUMN IF NOT EXISTS submission_idempotency_key varchar(200); +ALTER TABLE booking_requests + ADD COLUMN IF NOT EXISTS submission_fingerprint varchar(64); +ALTER TABLE booking_requests + ADD COLUMN IF NOT EXISTS setup_intent_id varchar(255); +UPDATE booking_requests +SET + submission_idempotency_key = COALESCE( + submission_idempotency_key, + 'legacy-' || id::text + ), + submission_fingerprint = COALESCE( + submission_fingerprint, + md5(id::text) || md5('booking-request:' || id::text) + ); +ALTER TABLE booking_requests ALTER COLUMN submission_idempotency_key SET NOT NULL; +ALTER TABLE booking_requests ALTER COLUMN submission_fingerprint SET NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_accepted_reservation_unique ON booking_requests (accepted_reservation_id); CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_property_submission_key_unique @@ -96,6 +118,24 @@ CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_setup_intent_unique CREATE INDEX IF NOT EXISTS booking_requests_property_status_idx ON booking_requests (property_id, status); +CREATE TABLE IF NOT EXISTS booking_request_consequences ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + booking_request_id uuid NOT NULL REFERENCES booking_requests(id), + kind varchar(50) NOT NULL, + payload jsonb NOT NULL, + status varchar(20) NOT NULL DEFAULT 'pending', + attempts integer NOT NULL DEFAULT 0, + claimed_at timestamptz, + last_attempt_at timestamptz, + last_error text, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS booking_request_consequences_property_request_kind_unique + ON booking_request_consequences (property_id, booking_request_id, kind); + CREATE TABLE IF NOT EXISTS booking_request_installments ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), property_id uuid NOT NULL REFERENCES properties(id), diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index 2a0b96b4..0edf1b0c 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -1172,9 +1172,23 @@ async function main() { updated_at timestamptz NOT NULL DEFAULT now() )`, `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_accepted_reservation_unique ON booking_requests (accepted_reservation_id)`, - `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_property_submission_key_unique ON booking_requests (property_id, submission_idempotency_key)`, - `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_setup_intent_unique ON booking_requests (setup_intent_id)`, `CREATE INDEX IF NOT EXISTS booking_requests_property_status_idx ON booking_requests (property_id, status)`, + `CREATE TABLE IF NOT EXISTS booking_request_consequences ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + property_id uuid NOT NULL REFERENCES properties(id), + booking_request_id uuid NOT NULL REFERENCES booking_requests(id), + kind varchar(50) NOT NULL, + payload jsonb NOT NULL, + status varchar(20) NOT NULL DEFAULT 'pending', + attempts integer NOT NULL DEFAULT 0, + claimed_at timestamptz, + last_attempt_at timestamptz, + last_error text, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS booking_request_consequences_property_request_kind_unique ON booking_request_consequences (property_id, booking_request_id, kind)`, `CREATE TABLE IF NOT EXISTS booking_request_installments ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), property_id uuid NOT NULL REFERENCES properties(id), @@ -1574,6 +1588,14 @@ async function main() { `ALTER TABLE booking_engine_config ALTER COLUMN booking_mode SET NOT NULL`, `ALTER TABLE booking_engine_config ALTER COLUMN payment_method_collection SET NOT NULL`, `ALTER TABLE booking_engine_config ALTER COLUMN form_questions SET NOT NULL`, + `ALTER TABLE booking_requests ADD COLUMN IF NOT EXISTS submission_idempotency_key varchar(200)`, + `ALTER TABLE booking_requests ADD COLUMN IF NOT EXISTS submission_fingerprint varchar(64)`, + `ALTER TABLE booking_requests ADD COLUMN IF NOT EXISTS setup_intent_id varchar(255)`, + `UPDATE booking_requests SET submission_idempotency_key = COALESCE(submission_idempotency_key, 'legacy-' || id::text), submission_fingerprint = COALESCE(submission_fingerprint, md5(id::text) || md5('booking-request:' || id::text))`, + `ALTER TABLE booking_requests ALTER COLUMN submission_idempotency_key SET NOT NULL`, + `ALTER TABLE booking_requests ALTER COLUMN submission_fingerprint SET NOT NULL`, + `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_property_submission_key_unique ON booking_requests (property_id, submission_idempotency_key)`, + `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_setup_intent_unique ON booking_requests (setup_intent_id)`, `DO $$ BEGIN IF EXISTS ( SELECT 1 FROM payments diff --git a/packages/database/src/schema/booking-request.ts b/packages/database/src/schema/booking-request.ts index f895e9d3..94c0bd88 100644 --- a/packages/database/src/schema/booking-request.ts +++ b/packages/database/src/schema/booking-request.ts @@ -115,6 +115,37 @@ export const bookingRequests = pgTable('booking_requests', { .on(table.acceptedReservationId), })); +/** + * Durable, replayable consequences emitted from Booking Request state changes. + * Kinds are strings rather than a database enum so later receipt/decision/payment + * consequences can extend this outbox without another enum migration. + */ +export type BookingRequestConsequenceKind = 'created_event'; +export type BookingRequestConsequenceStatus = 'pending' | 'processing' | 'completed'; + +export const bookingRequestConsequences = pgTable('booking_request_consequences', { + id: uuid('id').primaryKey().defaultRandom(), + propertyId: uuid('property_id').notNull().references(() => properties.id), + bookingRequestId: uuid('booking_request_id').notNull().references(() => bookingRequests.id), + kind: varchar('kind', { length: 50 }).$type().notNull(), + payload: jsonb('payload').$type>().notNull(), + status: varchar('status', { length: 20 }) + .$type() + .notNull() + .default('pending'), + attempts: integer('attempts').notNull().default(0), + claimedAt: timestamp('claimed_at', { withTimezone: true }), + lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }), + lastError: text('last_error'), + completedAt: timestamp('completed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + propertyRequestKindUnique: + uniqueIndex('booking_request_consequences_property_request_kind_unique') + .on(table.propertyId, table.bookingRequestId, table.kind), +})); + export const bookingRequestInstallments = pgTable('booking_request_installments', { id: uuid('id').primaryKey().defaultRandom(), propertyId: uuid('property_id').notNull().references(() => properties.id), diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index ebe3fa2a..53bb8c3e 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -256,6 +256,9 @@ export { bookingRequestEmailDeliveryKindEnum, bookingRequestEmailDeliveryStatusEnum, bookingRequests, + bookingRequestConsequences, + type BookingRequestConsequenceKind, + type BookingRequestConsequenceStatus, bookingRequestInstallments, bookingRequestPaymentAllocations, bookingRequestPaymentResolutions, From 5254ccc7ed508776ed0fa0dd22f527840a053e88 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 17:57:43 +0200 Subject: [PATCH 15/87] fix(booking-requests): stabilize webhook event id --- .../booking-request-submission.spec.ts | 5 +- .../booking-request.service.ts | 1 + .../connect/connect-events.service.spec.ts | 43 +++++- .../modules/connect/connect-events.service.ts | 31 ++-- .../webhook/webhook-delivery.service.spec.ts | 141 +++++++++++++++++- .../webhook/webhook-delivery.service.ts | 38 ++++- .../modules/webhook/webhook.service.spec.ts | 13 +- .../src/modules/webhook/webhook.service.ts | 11 +- .../src/booking-request-schema.spec.ts | 8 + .../src/migrations/0021_booking_requests.sql | 11 ++ packages/database/src/push-schema.ts | 3 + packages/database/src/schema/connect.ts | 20 ++- 12 files changed, 294 insertions(+), 31 deletions(-) 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 index 70551473..2feb360a 100644 --- a/apps/api/src/modules/booking-request/booking-request-submission.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-submission.spec.ts @@ -730,7 +730,10 @@ describe('BookingRequestService.submit', () => { }); expect(harness.storedConsequences[0]?.payload).toEqual(payload); expect(harness.storedAudits[0]?.['newValue']).toEqual(payload); - expect(harness.webhook.dispatchPersisted).toHaveBeenCalledWith(payload); + expect(harness.webhook.dispatchPersisted).toHaveBeenCalledWith( + payload, + CONSEQUENCE_ID, + ); expect(harness.values.mock.invocationCallOrder[0]).toBeLessThan( harness.webhook.dispatchPersisted.mock.invocationCallOrder[0]!, ); diff --git a/apps/api/src/modules/booking-request/booking-request.service.ts b/apps/api/src/modules/booking-request/booking-request.service.ts index ba1a3845..115b1689 100644 --- a/apps/api/src/modules/booking-request/booking-request.service.ts +++ b/apps/api/src/modules/booking-request/booking-request.service.ts @@ -517,6 +517,7 @@ export class BookingRequestService { try { await this.webhookService.dispatchPersisted( consequence.payload as unknown as WebhookPayload, + consequence.id, ); } catch (error: unknown) { await this.recordConsequenceFailure(consequence, error); 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..4a3d5ae9 100644 --- a/apps/api/src/modules/connect/connect-events.service.spec.ts +++ b/apps/api/src/modules/connect/connect-events.service.spec.ts @@ -165,7 +165,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 +192,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({ diff --git a/apps/api/src/modules/connect/connect-events.service.ts b/apps/api/src/modules/connect/connect-events.service.ts index c840f19d..c0fc05f5 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 { @@ -173,7 +174,7 @@ export class ConnectEventsService { * Listens to all events via wildcard. */ @OnEvent('**') - async handleEvent(payload: any) { + 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/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 index 0591d45e..69886697 100644 --- a/apps/api/src/modules/webhook/webhook.service.spec.ts +++ b/apps/api/src/modules/webhook/webhook.service.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { WebhookService, type WebhookPayload } from './webhook.service'; describe('WebhookService persisted dispatch', () => { - it('dispatches an already-audited payload without writing another audit row', async () => { + 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( @@ -21,12 +21,19 @@ describe('WebhookService persisted dispatch', () => { timestamp: '2026-08-24T17:15:00.000Z', }; - await service.dispatchPersisted(payload); + await service.dispatchPersisted( + payload, + 'bbbbbbbb-0000-4000-a000-000000000002', + ); expect(eventEmitter.emitAsync).toHaveBeenCalledWith( 'booking_request.created', - payload, + { + ...payload, + logicalEventId: 'bbbbbbbb-0000-4000-a000-000000000002', + }, ); + expect(payload).not.toHaveProperty('logicalEventId'); expect(db.insert).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/modules/webhook/webhook.service.ts b/apps/api/src/modules/webhook/webhook.service.ts index 9c3be987..5b372c53 100644 --- a/apps/api/src/modules/webhook/webhook.service.ts +++ b/apps/api/src/modules/webhook/webhook.service.ts @@ -11,6 +11,7 @@ export interface WebhookPayload { propertyId?: string; data: Record; timestamp: string; + logicalEventId?: string; } @Injectable() @@ -25,8 +26,14 @@ export class WebhookService { * 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): Promise { - await this.eventEmitter.emitAsync(payload.event, payload); + async dispatchPersisted( + payload: WebhookPayload, + logicalEventId: string, + ): Promise { + await this.eventEmitter.emitAsync(payload.event, { + ...payload, + logicalEventId, + } satisfies WebhookPayload); } /** diff --git a/packages/database/src/booking-request-schema.spec.ts b/packages/database/src/booking-request-schema.spec.ts index 62d93f96..17370c2f 100644 --- a/packages/database/src/booking-request-schema.spec.ts +++ b/packages/database/src/booking-request-schema.spec.ts @@ -6,6 +6,7 @@ import { bookingRequests, bookingRequestInstallments, payments, + webhookDeliveries, } from './schema/index.js'; describe('booking request schema', () => { @@ -29,6 +30,7 @@ describe('booking request schema', () => { expect(bookingEngineConfig.paymentMethodCollection).toBeDefined(); expect(payments.bookingRequestId).toBeDefined(); expect(payments.idempotencyKey).toBeDefined(); + expect(webhookDeliveries.logicalEventId).toBeDefined(); const indexNames = getTableConfig(bookingRequests).indexes.map((index) => index.config.name); expect(indexNames).toContain('booking_requests_property_submission_key_unique'); @@ -39,5 +41,11 @@ describe('booking request schema', () => { expect(consequenceIndexNames).toContain( 'booking_request_consequences_property_request_kind_unique', ); + + const deliveryIndexNames = getTableConfig(webhookDeliveries) + .indexes.map((index) => index.config.name); + expect(deliveryIndexNames).toContain( + 'webhook_deliveries_property_subscription_logical_event_unique', + ); }); }); diff --git a/packages/database/src/migrations/0021_booking_requests.sql b/packages/database/src/migrations/0021_booking_requests.sql index 320970e5..8a75e30d 100644 --- a/packages/database/src/migrations/0021_booking_requests.sql +++ b/packages/database/src/migrations/0021_booking_requests.sql @@ -136,6 +136,17 @@ CREATE TABLE IF NOT EXISTS booking_request_consequences ( CREATE UNIQUE INDEX IF NOT EXISTS booking_request_consequences_property_request_kind_unique ON booking_request_consequences (property_id, booking_request_id, kind); +-- A persisted Booking Request consequence is also the stable logical identity +-- of its external webhook. Existing legacy deliveries remain NULL and continue +-- to use their delivery-row ids. +ALTER TABLE IF EXISTS webhook_deliveries + ADD COLUMN IF NOT EXISTS logical_event_id uuid; +DO $$ BEGIN + IF to_regclass('webhook_deliveries') IS NOT NULL THEN + EXECUTE 'CREATE UNIQUE INDEX IF NOT EXISTS webhook_deliveries_property_subscription_logical_event_unique ON webhook_deliveries (property_id, subscription_id, logical_event_id)'; + END IF; +END $$; + CREATE TABLE IF NOT EXISTS booking_request_installments ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), property_id uuid NOT NULL REFERENCES properties(id), diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index 0edf1b0c..0b191f36 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -722,6 +722,7 @@ async function main() { id uuid PRIMARY KEY DEFAULT gen_random_uuid(), property_id uuid NOT NULL REFERENCES properties(id), subscription_id uuid NOT NULL REFERENCES agent_webhook_subscriptions(id), + logical_event_id uuid, event_type varchar(100) NOT NULL, payload jsonb NOT NULL, status webhook_delivery_status NOT NULL DEFAULT 'pending', @@ -1596,6 +1597,8 @@ async function main() { `ALTER TABLE booking_requests ALTER COLUMN submission_fingerprint SET NOT NULL`, `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_property_submission_key_unique ON booking_requests (property_id, submission_idempotency_key)`, `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_setup_intent_unique ON booking_requests (setup_intent_id)`, + `ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS logical_event_id uuid`, + `CREATE UNIQUE INDEX IF NOT EXISTS webhook_deliveries_property_subscription_logical_event_unique ON webhook_deliveries (property_id, subscription_id, logical_event_id)`, `DO $$ BEGIN IF EXISTS ( SELECT 1 FROM payments diff --git a/packages/database/src/schema/connect.ts b/packages/database/src/schema/connect.ts index 180cfc72..5b8f5520 100644 --- a/packages/database/src/schema/connect.ts +++ b/packages/database/src/schema/connect.ts @@ -1,4 +1,15 @@ -import { pgTable, uuid, varchar, boolean, timestamp, jsonb, integer, text, pgEnum } from 'drizzle-orm/pg-core'; +import { + pgTable, + uuid, + varchar, + boolean, + timestamp, + jsonb, + integer, + text, + pgEnum, + uniqueIndex, +} from 'drizzle-orm/pg-core'; import { properties } from './property.js'; /** @@ -46,6 +57,7 @@ export const webhookDeliveries = pgTable('webhook_deliveries', { id: uuid('id').primaryKey().defaultRandom(), propertyId: uuid('property_id').notNull().references(() => properties.id), subscriptionId: uuid('subscription_id').notNull().references(() => agentWebhookSubscriptions.id), + logicalEventId: uuid('logical_event_id'), eventType: varchar('event_type', { length: 100 }).notNull(), payload: jsonb('payload').notNull(), @@ -59,7 +71,11 @@ export const webhookDeliveries = pgTable('webhook_deliveries', { createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), deliveredAt: timestamp('delivered_at', { withTimezone: true }), -}); +}, (table) => ({ + propertySubscriptionLogicalEventUnique: + uniqueIndex('webhook_deliveries_property_subscription_logical_event_unique') + .on(table.propertyId, table.subscriptionId, table.logicalEventId), +})); /** * Connect API credentials — tenant-bound API keys for the /api/v1/connect/* surface. From 23d657a45d3634109316c939bf403e9cff0fddbe Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 18:26:01 +0200 Subject: [PATCH 16/87] feat(booking-requests): review and convert requests --- .../modules/ancillary/ancillary.service.ts | 96 +- .../booking-request-decision.spec.ts | 1049 +++++++++++++++++ .../booking-request.controller.ts | 76 ++ .../booking-request/booking-request.module.ts | 9 +- .../booking-request.service.ts | 610 +++++++++- .../dto/accept-booking-request.dto.ts | 22 + .../dto/deny-booking-request.dto.ts | 10 + .../dto/list-booking-requests.dto.ts | 74 ++ apps/api/src/modules/folio/folio.service.ts | 20 +- apps/api/src/modules/guest/guest.service.ts | 5 +- .../modules/rate-plan/rate-plan.service.ts | 6 +- .../reservation/reservation.service.ts | 91 +- .../database/src/schema/booking-request.ts | 8 +- 13 files changed, 1955 insertions(+), 121 deletions(-) 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.controller.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/deny-booking-request.dto.ts create mode 100644 apps/api/src/modules/booking-request/dto/list-booking-requests.dto.ts diff --git a/apps/api/src/modules/ancillary/ancillary.service.ts b/apps/api/src/modules/ancillary/ancillary.service.ts index e6a6275f..328ee7ca 100644 --- a/apps/api/src/modules/ancillary/ancillary.service.ts +++ b/apps/api/src/modules/ancillary/ancillary.service.ts @@ -66,8 +66,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 +198,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( @@ -252,9 +254,14 @@ 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, + ) { + 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 +270,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, @@ -282,20 +289,22 @@ export class AncillaryService { }) .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, + { + reservationId, + serviceId: service.id, + serviceName: service.name, + quantity, + unitPrice, + postingRule: row.postingRule, + }, + dto.propertyId, + ); + } return row; } @@ -354,10 +363,11 @@ 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); + async ensurePackageComponents(reservationId: string, propertyId: string, tx?: any) { + const db = tx ?? this.db; + const reservation = await this.findReservation(reservationId, propertyId, db); - const components = await this.db + const components = await db .select() .from(ratePlanComponents) .where( @@ -371,7 +381,7 @@ export class AncillaryService { return []; } - const existing = await this.db + const existing = await db .select({ serviceId: reservationServices.serviceId }) .from(reservationServices) .where( @@ -388,7 +398,7 @@ 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) { unitPrice = component.amountOverride; @@ -398,7 +408,7 @@ export class AncillaryService { unitPrice = service.price; } - const [row] = await this.db + const [row] = await db .insert(reservationServices) .values({ propertyId, @@ -414,20 +424,22 @@ 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, + { + reservationId, + serviceId: service.id, + serviceName: service.name, + sourceChannel: 'package', + quantity: row.quantity, + unitPrice, + }, + propertyId, + ); + } attached.push(row); } 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..d1942d95 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-decision.spec.ts @@ -0,0 +1,1049 @@ +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, + bookingRequestPaymentResolutions, + bookingRequests, + bookings, + folios, + guests, + payments, + ratePlanComponents, + reservationGuests, + reservationServices, + reservations, + roomTypes, + services, +} from '@telivityhaip/database'; +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 { 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: [], + 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', +}; + +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; + 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), + 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, + }; +} + +type State = { + requests: RequestRow[]; + guests: Array>; + reservations: Array>; + folios: Array>; + payments: Array>; + resolutions: 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 === bookingRequestPaymentResolutions) return state.resolutions; + 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; + const resolveRows = () => { + const rows = structuredClone(rowsFor(table)); + if (selection && Object.keys(selection).length === 1 && 'count' in selection) { + return [{ count: rows.length }]; + } + 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(() => 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 makeHarness(requests: RequestRow[] = [pendingRequest()]) { + const state: State = { + requests: structuredClone(requests), + guests: [], + reservations: [], + folios: [], + payments: [], + resolutions: [], + 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 = { + 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 service = new (BookingRequestService as any)( + database.db, + config, + bookingEngine, + availability, + ratePlan, + savedPaymentMethod, + webhook, + guest, + reservation, + folio, + ancillary, + ) as BookingRequestService & Record Promise>; + + return { + service, + state, + database, + bookingEngine, + savedPaymentMethod, + webhook, + guest, + reservation, + folio, + ancillary, + setAvailability(value: boolean) { + hasAvailability = value; + }, + get reservationCreates() { + return reservationCreates; + }, + dispatchTransactionStates, + quoteTransactionStates, + }; +} + +async function call( + service: BookingRequestService & Record Promise>, + method: 'list' | 'findById' | '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.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 blankDenial = await validate(plainToInstance( + denyDtoModule.DenyBookingRequestDto, + { reason: '' }, + )); + expect(missingScope.some((error) => error.property === 'propertyId')).toBe(true); + expect(invalidSource.some((error) => error.property === 'priceSource')).toBe(true); + expect(blankDenial.some((error) => error.property === 'reason')).toBe(true); + }); +}); + +describe('BookingRequestService staff reads', () => { + 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('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); + }); +}); + +describe('BookingRequestService acceptance', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + 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 }, + actor, + ]); + + expect(result.id).toBe(RESERVATION_ID); + expect(result.totalAmount).toBe(expectedTotal); + 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.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.quoteTransactionStates).toEqual([false]); + 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' }, + 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('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' }, + 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' }, + actor, + ])).rejects.toBeInstanceOf(ConflictException); + expect(harness.state.requests[0]?.status).toBe('pending'); + expect(harness.state.reservations).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' }, + actor, + ]), + call(harness.service, 'accept', [ + REQUEST_ID, + PROPERTY_ID, + { priceSource: 'submitted' }, + actor, + ]), + ]); + + expect(first.id).toBe(RESERVATION_ID); + expect(second.id).toBe(RESERVATION_ID); + expect(harness.reservationCreates).toBe(1); + expect(harness.state.reservations).toHaveLength(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' }, + actor, + ]); + + expect(result).toMatchObject({ id: RESERVATION_ID, propertyId: PROPERTY_ID }); + 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' }, + actor, + ]); + + expect(harness.state.payments[0]).toMatchObject({ + id: PAYMENT_ID, + bookingRequestId: REQUEST_ID, + folioId: FOLIO_ID, + }); + }); + + 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' }, + 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('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).toMatchObject({ + id: REQUEST_ID, + status: 'denied', + denialReason: 'Unable to accommodate', + decidedBy: actor.userId, + }); + 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); + }); + + 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('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), + 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), + 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, + 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.controller.ts b/apps/api/src/modules/booking-request/booking-request.controller.ts new file mode 100644 index 00000000..09ab4d21 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request.controller.ts @@ -0,0 +1,76 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + ParseUUIDPipe, + 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 { 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 { DenyBookingRequestDto } from './dto/deny-booking-request.dto'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import { ListBookingRequestsDto } from './dto/list-booking-requests.dto'; + +@ApiTags('booking-requests') +@Controller('booking-requests') +export class BookingRequestController { + constructor( + @Inject(BookingRequestService) private readonly service: BookingRequestService, + ) {} + + @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); + } + + @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); + } +} diff --git a/apps/api/src/modules/booking-request/booking-request.module.ts b/apps/api/src/modules/booking-request/booking-request.module.ts index 2c99ed98..c9d53fc5 100644 --- a/apps/api/src/modules/booking-request/booking-request.module.ts +++ b/apps/api/src/modules/booking-request/booking-request.module.ts @@ -1,4 +1,5 @@ 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'; @@ -6,7 +7,10 @@ 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 { BookingRequestController } from './booking-request.controller'; import { BookingRequestPublicController } from './booking-request-public.controller'; import { BookingRequestService } from './booking-request.service'; @@ -17,8 +21,11 @@ import { BookingRequestService } from './booking-request.service'; RatePlanModule, PaymentModule, WebhookModule, + GuestModule, + FolioModule, + AncillaryModule, ], - controllers: [BookingRequestPublicController], + controllers: [BookingRequestPublicController, BookingRequestController], providers: [ BookingRequestService, BookingKeyGuard, diff --git a/apps/api/src/modules/booking-request/booking-request.service.ts b/apps/api/src/modules/booking-request/booking-request.service.ts index 115b1689..075e850a 100644 --- a/apps/api/src/modules/booking-request/booking-request.service.ts +++ b/apps/api/src/modules/booking-request/booking-request.service.ts @@ -5,21 +5,41 @@ import { Inject, Injectable, Logger, + NotFoundException, } from '@nestjs/common'; import { auditLogs, bookingEngineConfig, bookingRequestConsequences, + bookingRequestPaymentResolutions, bookingRequests, + payments, + reservations, } from '@telivityhaip/database'; import type { BookingFormQuestion, PaymentMethodCollection, } from '@telivityhaip/database'; import { createHash } from 'node:crypto'; -import { and, eq } from 'drizzle-orm'; +import { + and, + desc, + eq, + gte, + ilike, + isNotNull, + isNull, + lte, + or, + sql, +} 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 { AncillaryService } from '../ancillary/ancillary.service'; import { BookingEngineConfigService } from '../booking-engine/booking-engine-config.service'; import { BookingEngineService } from '../booking-engine/booking-engine.service'; import { @@ -32,15 +52,35 @@ import { 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 { + assertDenialMoneyResolved, + resolveAcceptedTotal, + type BookingRequestPriceSource, +} from './booking-request-money'; +import { assertBookingRequestTransition } from './booking-request-state'; +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'; +export type AcceptBookingRequestInput = { + priceSource: BookingRequestPriceSource; + customTotal?: string; + customReason?: string; +}; + +export type { AuditActor } from '../../common/audit/audit-actor'; + export type BookingRequestAcknowledgement = { requestId: string; status: 'pending'; @@ -77,6 +117,10 @@ type CreatedConsequence = typeof bookingRequestConsequences.$inferSelect; 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() @@ -96,8 +140,360 @@ export class BookingRequestService { @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, ) {} + 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 [selected, countRows] = await Promise.all([ + this.db + .select() + .from(bookingRequests) + .where(where) + .orderBy(desc(bookingRequests.createdAt)) + .limit(limit) + .offset(offset), + this.db + .select({ count: sql`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); + const total = Number(countRows[0]?.count ?? 0); + return { data, total, page, limit, hasMore: offset + data.length < total }; + } + + async findById(id: string, propertyId: string) { + return this.findRequest(this.db, id, propertyId); + } + + 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.deliverConsequencesBestEffort(id, propertyId); + return linked; + } + if (initial.status === 'denied') { + throw new ConflictException('Cannot accept a denied booking request'); + } + + const currentQuote = await this.bookingEngineService.quote(propertyId, { + roomTypeId: initial.roomTypeId, + ratePlanId: initial.ratePlanId, + checkIn: initial.arrivalDate, + checkOut: initial.departureDate, + adults: initial.adults, + children: initial.children, + serviceIds: initial.serviceIds, + }).catch((error: unknown) => this.throwAcceptanceError(error)); + const preliminaryPrice = resolveAcceptedTotal({ + source: input.priceSource, + submittedTotal: this.quoteTotal(initial.submittedQuoteSnapshot), + currentTotal: currentQuote.grandTotal, + customTotal: input.customTotal, + customReason: input.customReason, + }); + + 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), + }; + } + if (locked.status === 'denied') { + throw new ConflictException('Cannot accept a denied booking request'); + } + + assertBookingRequestTransition(locked.status, 'accepted'); + const price = resolveAcceptedTotal({ + source: preliminaryPrice.source, + submittedTotal: this.quoteTotal(locked.submittedQuoteSnapshot), + currentTotal: currentQuote.grandTotal, + customTotal: input.customTotal, + customReason: input.customReason, + }); + 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: price.total.toFixed(2), + currencyCode: locked.currencyCode, + adults: locked.adults, + children: locked.children, + specialRequests: locked.specialRequests ?? undefined, + source: 'direct', + channelCode: 'booking_request', + }, undefined, tx); + const folio = await this.folioService.createAutoFolio({ + id: reservation.id, + propertyId, + bookingId: reservation.bookingId, + guestId: guest.id, + currencyCode: locked.currencyCode, + }, tx); + + const attachedServices: Array> = []; + for (const serviceId of new Set(locked.serviceIds ?? [])) { + attachedServices.push(await this.ancillaryService.attachToReservation( + reservation.id, + { propertyId, serviceId, sourceChannel: 'booking_engine' }, + tx, + )); + } + attachedServices.push(...await this.ancillaryService.ensurePackageComponents( + reservation.id, + propertyId, + tx, + )); + + 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: price.source, + acceptedTotal: price.total.toFixed(2), + customPriceReason: price.customReason ?? null, + 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'); + } + + await this.insertConsequence(tx, propertyId, id, ACCEPTED_CONSEQUENCE_KIND, { + event: 'booking_request.accepted', + entityType: 'booking_request', + entityId: id, + propertyId, + data: { + requestId: id, + reservationId: reservation.id, + folioId: folio.id, + priceSource: price.source, + acceptedTotal: price.total.toFixed(2), + }, + timestamp: decidedAt.toISOString(), + }); + 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; + await this.insertConsequence(tx, propertyId, id, `service:${attached['id']}`, { + event: 'reservation.service_attached', + entityType: 'reservation_service', + entityId: attached['id'], + propertyId, + data: { + reservationId: reservation.id, + serviceId: attached['serviceId'] ?? null, + }, + timestamp: decidedAt.toISOString(), + }); + } + await tx.insert(auditLogs).values({ + propertyId, + action: 'update', + entityType: 'booking_request', + entityId: id, + ...actorFields(actor), + previousValue: { status: 'pending' }, + newValue: { + status: 'accepted', + reservationId: reservation.id, + folioId: folio.id, + priceSource: price.source, + acceptedTotal: price.total.toFixed(2), + customPriceReason: price.customReason ?? null, + }, + description: 'Booking request accepted', + }); + return { reservation }; + }).catch((error: unknown) => this.throwAcceptanceError(error)); + + await this.deliverConsequencesBestEffort(id, propertyId); + return 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); + 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 + && row.originalPaymentId == null); + const scopedResolutions = resolutionRows.filter((row) => + row.propertyId === propertyId && row.bookingRequestId === id); + assertDenialMoneyResolved(scopedMovements, scopedResolutions); + + 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'); + } + await this.insertConsequence(tx, propertyId, id, DENIED_CONSEQUENCE_KIND, { + event: 'booking_request.denied', + entityType: 'booking_request', + entityId: id, + propertyId, + data: { requestId: id, status: 'denied' }, + timestamp: decidedAt.toISOString(), + }); + await tx.insert(auditLogs).values({ + propertyId, + action: 'update', + entityType: 'booking_request', + entityId: id, + ...actorFields(actor), + previousValue: { status: 'pending' }, + newValue: { status: 'denied', denialReason: reason }, + description: 'Booking request denied', + }); + return updated; + }); + + await this.deliverConsequencesBestEffort(id, propertyId); + return denied; + } + async createPaymentMethodSetup( propertyId: string, dto: CreateRequestCardSetupDto, @@ -452,6 +848,123 @@ export class BookingRequestService { .join(',')}}`; } + 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 quoteTotal(snapshot: unknown): string | null { + if (!snapshot || typeof snapshot !== 'object') return null; + const total = (snapshot as Record)['grandTotal']; + return typeof total === 'string' ? total : null; + } + + 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, + ): 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, + }); + await tx.insert(auditLogs).values({ + propertyId, + action: 'create', + entityType: payload.entityType, + entityId: payload.entityId, + description: `Webhook event: ${payload.event}`, + newValue: persistedPayload, + }); + } + private async findExistingRequest( db: Pick, propertyId: string, @@ -510,51 +1023,75 @@ export class BookingRequestService { requestId: string, propertyId: string, ): Promise { - try { - const consequence = await this.claimCreatedConsequence(requestId, propertyId); - if (!consequence) return; - - 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 its created consequence failed`, - error instanceof Error ? error.stack : undefined, - ); - return; - } + await this.deliverConsequencesBestEffort(requestId, propertyId); + } - const completedAt = new Date(); - await this.db - .update(bookingRequestConsequences) - .set({ - status: 'completed', - claimedAt: null, - lastError: null, - completedAt, - updatedAt: completedAt, - }) + private async deliverConsequencesBestEffort( + requestId: string, + propertyId: string, + ): Promise { + try { + const candidates = await this.db + .select() + .from(bookingRequestConsequences) .where(and( - eq(bookingRequestConsequences.id, consequence.id), eq(bookingRequestConsequences.propertyId, propertyId), - eq(bookingRequestConsequences.status, 'processing'), - eq(bookingRequestConsequences.claimedAt, consequence.claimedAt!), + 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 its created consequence state could not be updated`, + `Booking request ${requestId} was committed but consequence state could not be updated`, error instanceof Error ? error.stack : undefined, ); } } - private async claimCreatedConsequence( + private async claimConsequence( requestId: string, propertyId: string, + kind: string, ): Promise { return this.db.transaction(async (tx) => { const rows = await tx @@ -563,13 +1100,16 @@ export class BookingRequestService { .where(and( eq(bookingRequestConsequences.propertyId, propertyId), eq(bookingRequestConsequences.bookingRequestId, requestId), - eq(bookingRequestConsequences.kind, CREATED_CONSEQUENCE_KIND), + eq( + bookingRequestConsequences.kind, + kind as CreatedConsequence['kind'], + ), )) .for('update'); const consequence = rows.find((candidate) => candidate.propertyId === propertyId && candidate.bookingRequestId === requestId - && candidate.kind === CREATED_CONSEQUENCE_KIND); + && candidate.kind === kind); if (!consequence || consequence.status === 'completed') return undefined; if ( 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..d9553021 --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/accept-booking-request.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, 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]; + + @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/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-requests.dto.ts b/apps/api/src/modules/booking-request/dto/list-booking-requests.dto.ts new file mode 100644 index 00000000..6ae22fb9 --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/list-booking-requests.dto.ts @@ -0,0 +1,74 @@ +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 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({ 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/folio/folio.service.ts b/apps/api/src/modules/folio/folio.service.ts index f399d584..105a915f 100644 --- a/apps/api/src/modules/folio/folio.service.ts +++ b/apps/api/src/modules/folio/folio.service.ts @@ -51,13 +51,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; } @@ -584,7 +586,7 @@ export class FolioService { bookingId?: string | null; guestId: string; currencyCode: string; - }) { + }, tx?: any) { return this.create({ propertyId: reservation.propertyId, reservationId: reservation.id, @@ -592,7 +594,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/rate-plan/rate-plan.service.ts b/apps/api/src/modules/rate-plan/rate-plan.service.ts index 911e4da3..483d9f1b 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( diff --git a/apps/api/src/modules/reservation/reservation.service.ts b/apps/api/src/modules/reservation/reservation.service.ts index 3b4bfa53..62cf5b4e 100644 --- a/apps/api/src/modules/reservation/reservation.service.ts +++ b/apps/api/src/modules/reservation/reservation.service.ts @@ -50,9 +50,14 @@ export class ReservationService { private readonly ratePlanService: RatePlanService, ) {} - async create(dto: CreateReservationDto, opts?: { confirmationNumber?: string }) { + async create( + dto: CreateReservationDto, + opts?: { confirmationNumber?: string }, + 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)); @@ -86,31 +91,53 @@ export class ReservationService { // 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, ); + // 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, + ); + } + // 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) => { + const createInTransaction = async (transaction: any) => { // Check inventory availability inside the tx const availability = await this.availabilityService.searchAvailability( dto.propertyId, dto.arrivalDate, dto.departureDate, dto.roomTypeId, - tx, + transaction, ); const roomTypeAvail = availability.find((a: any) => a.roomTypeId === dto.roomTypeId); if (!roomTypeAvail || roomTypeAvail.available <= 0) { @@ -119,7 +146,7 @@ export class ReservationService { ); } - const [booking] = await tx + const [booking] = await transaction .insert(bookings) .values({ propertyId: dto.propertyId, @@ -131,7 +158,7 @@ export class ReservationService { }) .returning(); - const [reservation] = await tx + const [reservation] = await transaction .insert(reservations) .values({ propertyId: dto.propertyId, @@ -152,7 +179,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,21 +187,26 @@ 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; } @@ -1325,8 +1357,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))); diff --git a/packages/database/src/schema/booking-request.ts b/packages/database/src/schema/booking-request.ts index 94c0bd88..f91de292 100644 --- a/packages/database/src/schema/booking-request.ts +++ b/packages/database/src/schema/booking-request.ts @@ -120,7 +120,13 @@ export const bookingRequests = pgTable('booking_requests', { * Kinds are strings rather than a database enum so later receipt/decision/payment * consequences can extend this outbox without another enum migration. */ -export type BookingRequestConsequenceKind = 'created_event'; +export type BookingRequestConsequenceKind = + | 'created_event' + | 'accepted_event' + | 'denied_event' + | 'reservation_created_event' + | 'folio_created_event' + | `service:${string}`; export type BookingRequestConsequenceStatus = 'pending' | 'processing' | 'completed'; export const bookingRequestConsequences = pgTable('booking_request_consequences', { From 1ac9f006d135613ae4fddca577bad0784f844b01 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 19:13:57 +0200 Subject: [PATCH 17/87] fix(booking-requests): harden acceptance decisions --- .../common/crypto/confirmation-number.spec.ts | 13 + .../src/common/crypto/confirmation-number.ts | 21 + .../ancillary-accepted-pricing.spec.ts | 129 ++++++ .../ancillary/ancillary.service.spec.ts | 10 +- .../modules/ancillary/ancillary.service.ts | 125 ++++-- .../ancillary/reservation-service-event.ts | 23 ++ .../booking-engine-config.service.ts | 12 +- .../booking-engine.service.spec.ts | 125 +++++- .../booking-engine/booking-engine.service.ts | 100 ++++- ...king-request-consequence-worker.service.ts | 51 +++ .../booking-request-decision.spec.ts | 381 +++++++++++++++++- .../booking-request-pricing.spec.ts | 140 +++++++ .../booking-request-pricing.ts | 238 +++++++++++ .../booking-request/booking-request.module.ts | 2 + .../booking-request.service.ts | 175 +++++--- .../dto/booking-request-response.dto.ts | 137 +++++++ .../connect/connect-booking.service.ts | 15 +- .../src/modules/folio/folio.service.spec.ts | 50 +++ apps/api/src/modules/folio/folio.service.ts | 94 ++++- .../night-audit/night-audit.service.spec.ts | 118 ++++++ .../night-audit/night-audit.service.ts | 66 ++- apps/api/src/modules/policy/policy.service.ts | 11 +- .../modules/rate-plan/rate-plan.service.ts | 28 +- .../reservation/availability.service.ts | 62 ++- .../reservation-assert-sellable.spec.ts | 143 ++++++- .../reservation/reservation.service.ts | 109 +++-- apps/api/src/modules/tax/tax.service.ts | 17 +- .../src/booking-request-schema.spec.ts | 2 + .../0022_booking_request_accepted_pricing.sql | 5 + packages/database/src/push-schema.ts | 1 + packages/database/src/schema/index.ts | 4 + packages/database/src/schema/reservation.ts | 46 +++ 32 files changed, 2228 insertions(+), 225 deletions(-) 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/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-request/booking-request-consequence-worker.service.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/dto/booking-request-response.dto.ts create mode 100644 packages/database/src/migrations/0022_booking_request_accepted_pricing.sql 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/modules/ancillary/ancillary-accepted-pricing.spec.ts b/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts new file mode 100644 index 00000000..efeba4e5 --- /dev/null +++ b/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AncillaryService } from './ancillary.service'; + +function stagedSelect(stages: any[][]) { + let index = 0; + return vi.fn(() => { + const rows = stages[index++] ?? []; + const promise = Promise.resolve(rows); + const chain: any = { + from: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), + where: vi.fn(() => chain), + limit: vi.fn(() => promise), + then: promise.then.bind(promise), + }; + return chain; + }); +} + +describe('AncillaryService accepted operational pricing', () => { + 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', + lineItems: [ + { date: '2026-10-01', amount: '15.00', taxAmount: '2.00' }, + { date: '2026-10-02', amount: '15.00', taxAmount: '2.00' }, + ], + }], + }, + }; + const db = { + select: stagedSelect([ + [{ + rs: { + id: 'rs-1', + serviceId: 'svc-1', + unitPrice: '99.00', + quantity: 1, + chargeType: 'parking', + currencyCode: 'EUR', + postingRule: 'per_night', + sourceChannel: 'booking_engine', + }, + serviceName: 'Parking', + reservation, + }], + [{ id: 'folio-1' }], + [], + ]), + }; + const folio = { + postCharge: vi.fn(), + postChargeFromSnapshot: vi.fn().mockResolvedValue({ id: 'charge-1' }), + }; + 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.postChargeFromSnapshot).toHaveBeenCalledWith( + 'folio-1', + expect.objectContaining({ amount: '15.00', currencyCode: 'EUR' }), + '2.00', + ); + 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.service.spec.ts b/apps/api/src/modules/ancillary/ancillary.service.spec.ts index 7f19720f..fffdf130 100644 --- a/apps/api/src/modules/ancillary/ancillary.service.spec.ts +++ b/apps/api/src/modules/ancillary/ancillary.service.spec.ts @@ -178,7 +178,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', ); }); diff --git a/apps/api/src/modules/ancillary/ancillary.service.ts b/apps/api/src/modules/ancillary/ancillary.service.ts index 328ee7ca..12202001 100644 --- a/apps/api/src/modules/ancillary/ancillary.service.ts +++ b/apps/api/src/modules/ancillary/ancillary.service.ts @@ -23,6 +23,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; @@ -258,6 +265,7 @@ export class AncillaryService { reservationId: string, dto: AttachReservationServiceDto, tx?: any, + pricingOverride?: ReservationServicePricingOverride, ) { const db = tx ?? this.db; const reservation = await this.findReservation(reservationId, dto.propertyId, db); @@ -278,13 +286,13 @@ 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(); @@ -294,19 +302,12 @@ export class AncillaryService { 'reservation.service_attached', 'reservation_service', row.id, - { - reservationId, - serviceId: service.id, - serviceName: service.name, - quantity, - unitPrice, - postingRule: row.postingRule, - }, + reservationServiceAttachedPayload(row, service.name), dto.propertyId, ); } - return row; + return { ...row, serviceName: service.name }; } async listForReservation(propertyId: string, reservationId: string) { @@ -363,7 +364,15 @@ 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, tx?: any) { + 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); @@ -400,7 +409,13 @@ export class AncillaryService { 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'; @@ -416,7 +431,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, @@ -429,19 +444,12 @@ export class AncillaryService { 'reservation.service_attached', 'reservation_service', row.id, - { - reservationId, - serviceId: service.id, - serviceName: service.name, - sourceChannel: 'package', - quantity: row.quantity, - unitPrice, - }, + reservationServiceAttachedPayload(row, service.name), propertyId, ); } - attached.push(row); + attached.push({ ...row, serviceName: service.name }); } return attached; @@ -498,21 +506,37 @@ export class AncillaryService { continue; } - const amount = new Decimal(rs.unitPrice).times(rs.quantity).toFixed(2); + const acceptedLine = this.acceptedServiceLine( + reservation, + rs.serviceId, + serviceDate, + true, + ); + const amount = acceptedLine?.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, { + const chargeInput = { propertyId, type: rs.chargeType, description, amount, - currencyCode: rs.currencyCode, + currencyCode: acceptedLine?.currencyCode ?? rs.currencyCode, serviceDate: new Date(serviceDate + 'T00:00:00Z').toISOString(), guestId: reservation.guestId, - }); + }; + if (acceptedLine) { + await this.folioService.postChargeFromSnapshot( + folio.id, + chargeInput, + acceptedLine.taxAmount, + ); + } else { + await this.folioService.postCharge(folio.id, chargeInput); + } } const [updated] = await this.db @@ -609,22 +633,36 @@ export class AncillaryService { continue; } - const amount = new Decimal(rs.unitPrice).times(rs.quantity).toFixed(2); + const acceptedLine = this.acceptedServiceLine( + reservation, + rs.serviceId, + date, + false, + ); + 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, description, amount, - currencyCode: rs.currencyCode, + currencyCode: acceptedLine?.currencyCode ?? rs.currencyCode, serviceDate: new Date(date + 'T00:00:00Z').toISOString(), guestId: reservation.guestId, - }); + }; + const charge = acceptedLine + ? await this.folioService.postChargeFromSnapshot( + folio.id, + chargeInput, + acceptedLine.taxAmount, + ) + : await this.folioService.postCharge(folio.id, chargeInput); // Stay confirmed until stay ends — idempotency via charge existence. await this.webhookService.emit( @@ -656,4 +694,27 @@ export class AncillaryService { count: posted.length, }; } + + private acceptedServiceLine( + reservation: any, + serviceId: string, + date: string, + useFirstLine: boolean, + ): { amount: string; taxAmount: string; currencyCode: 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 { + amount: line.amount, + taxAmount: line.taxAmount, + currencyCode: pricing.currencyCode, + }; + } } 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-config.service.ts b/apps/api/src/modules/booking-engine/booking-engine-config.service.ts index 164242db..cb7c141f 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 @@ -46,14 +46,16 @@ export class BookingEngineConfigService { constructor(@Inject(DRIZZLE) private readonly db: any) {} /** 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(); @@ -64,8 +66,8 @@ 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 formQuestions = validateQuestionDefinitions( (cfg.formQuestions ?? []) as BookingFormQuestion[], ) 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 30c50973..83a7b117 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 @@ -22,7 +22,10 @@ function makeService(overrides: Partial> = {}) { 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' }), @@ -98,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', () => { 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 7f1dd2df..89bc6b90 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,15 @@ 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 }, + ) { + 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 +150,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'); } @@ -152,18 +165,36 @@ export class BookingEngineService { dto.checkIn, dto.checkOut, dto.roomTypeId, + db, + ); + assertFullStayAvailability( + availability, + dto.roomTypeId, + dto.checkIn, + dto.checkOut, ); - 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 +213,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 +227,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 +244,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 +262,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 +277,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 +294,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 +313,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 +331,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 +354,7 @@ export class BookingEngineService { const cancellationPolicy = await this.policyService.getPolicySummary( propertyId, dto.ratePlanId, + db, ); return { @@ -365,7 +427,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, 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..c42b8c35 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-consequence-worker.service.ts @@ -0,0 +1,51 @@ +import { + Inject, + Injectable, + Logger, +} from '@nestjs/common'; +import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { BookingRequestService } from './booking-request.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, + ) {} + + 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, + ); + } finally { + this.running = false; + } + } +} 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 index d1942d95..52536682 100644 --- a/apps/api/src/modules/booking-request/booking-request-decision.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-decision.spec.ts @@ -48,7 +48,10 @@ const submittedQuote = { checkOut: '2026-10-03', nights: 2, currencyCode: 'EUR', - lineItems: [], + 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: [], @@ -69,6 +72,10 @@ const currentQuote = { 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 = { @@ -346,6 +353,7 @@ function makeHarness(requests: RequestRow[] = [pendingRequest()]) { }), }; const reservation = { + lockInventory: vi.fn(async () => undefined), create: vi.fn(async (dto: Record) => { reservationCreates += 1; if (!hasAvailability) { @@ -514,6 +522,68 @@ describe('BookingRequestService staff reads', () => { 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, + })); + 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', '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 }), @@ -547,8 +617,14 @@ describe('BookingRequestService acceptance', () => { actor, ]); - expect(result.id).toBe(RESERVATION_ID); - expect(result.totalAmount).toBe(expectedTotal); + expect(result).toEqual({ + requestId: REQUEST_ID, + status: 'accepted', + reservationId: RESERVATION_ID, + folioId: FOLIO_ID, + totalAmount: expectedTotal, + currencyCode: 'EUR', + }); expect(harness.state.requests[0]).toMatchObject({ status: 'accepted', acceptedPriceSource: priceSource, @@ -560,6 +636,13 @@ describe('BookingRequestService acceptance', () => { 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, @@ -572,7 +655,7 @@ describe('BookingRequestService acceptance', () => { 'Webhook event: folio.created', ]), ); - expect(harness.quoteTransactionStates).toEqual([false]); + expect(harness.quoteTransactionStates).toEqual([true]); expect(harness.dispatchTransactionStates.every((active) => !active)).toBe(true); }, ); @@ -624,6 +707,23 @@ describe('BookingRequestService acceptance', () => { 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' }, + actor, + ])).rejects.toThrow(/currency/i); + expect(harness.state.requests[0]?.status).toBe('pending'); + expect(harness.state.reservations).toHaveLength(0); + }); + it('serializes simultaneous acceptance and creates exactly one reservation', async () => { const harness = makeHarness(); @@ -642,12 +742,79 @@ describe('BookingRequestService acceptance', () => { ]), ]); - expect(first.id).toBe(RESERVATION_ID); - expect(second.id).toBe(RESERVATION_ID); + expect(first).toEqual(second); + expect(first).toEqual({ + requestId: REQUEST_ID, + status: 'accepted', + reservationId: RESERVATION_ID, + folioId: FOLIO_ID, + totalAmount: '220.00', + currencyCode: 'EUR', + }); expect(harness.reservationCreates).toBe(1); expect(harness.state.reservations).toHaveLength(1); }); + 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' }, + actor, + ]), + call(second.service, 'accept', [ + otherRequestId, + PROPERTY_ID, + { priceSource: 'current' }, + 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', @@ -670,7 +837,14 @@ describe('BookingRequestService acceptance', () => { actor, ]); - expect(result).toMatchObject({ id: RESERVATION_ID, propertyId: PROPERTY_ID }); + expect(result).toEqual({ + requestId: REQUEST_ID, + status: 'accepted', + reservationId: RESERVATION_ID, + folioId: FOLIO_ID, + totalAmount: '220.00', + currencyCode: 'EUR', + }); expect(harness.reservationCreates).toBe(0); }); @@ -699,6 +873,84 @@ describe('BookingRequestService acceptance', () => { }); }); + 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] }), + ]); + harness.bookingEngine.quote.mockResolvedValue({ + ...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.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' }, + 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 })]); @@ -764,11 +1016,11 @@ describe('BookingRequestService denial', () => { actor, ]); - expect(result).toMatchObject({ - id: REQUEST_ID, + expect(result).toEqual({ + requestId: REQUEST_ID, status: 'denied', denialReason: 'Unable to accommodate', - decidedBy: actor.userId, + decidedAt: expect.any(Date), }); expect(harness.state.requests).toHaveLength(1); expect(harness.state.payments).toHaveLength(1); @@ -782,6 +1034,33 @@ describe('BookingRequestService denial', () => { expect(harness.dispatchTransactionStates.every((active) => !active)).toBe(true); }); + 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 })]); @@ -795,6 +1074,77 @@ describe('BookingRequestService denial', () => { }); }); +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 = { @@ -843,6 +1193,9 @@ describe('canonical creation transaction seams', () => { 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), @@ -891,6 +1244,9 @@ describe('canonical creation transaction seams', () => { 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 }] @@ -916,6 +1272,11 @@ describe('canonical creation transaction seams', () => { 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, }]), }; 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..79639caa --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-pricing.spec.ts @@ -0,0 +1,140 @@ +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' }, + ], + }], + 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('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); + }); +}); 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..f6658b76 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-pricing.ts @@ -0,0 +1,238 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import type { AcceptedPricingSnapshot } from '@telivityhaip/database'; +import Decimal from 'decimal.js'; +import 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): Decimal { + const raw = string(value, label); + try { + const parsed = new Decimal(raw); + if (!parsed.isFinite() || parsed.isNegative()) throw new Error('invalid'); + return parsed.toDecimalPlaces(2); + } catch { + throw new ConflictException(`${label} is not valid money`); + } +} + +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, +): Omit { + 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`).toFixed(2), + taxAmount: money(row['tax'], `Night ${date} tax amount`).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`).toFixed(2), + quantity: integer(row['quantity'], `Service ${index + 1} quantity`), + lineTotal: money(row['lineTotal'], `Service ${index + 1} total`).toFixed(2), + taxTotal: money(row['taxTotal'], `Service ${index + 1} tax`).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`).toFixed(2), + taxAmount: money(line['tax'], `Service ${index + 1} line tax`).toFixed(2), + }; + }), + }; + }); + + const roomTotal = money(quote['roomTotal'], 'Room total'); + const taxTotal = money(quote['taxTotal'], 'Room tax total'); + const servicesTotal = money(quote['servicesTotal'], 'Services total'); + const servicesTaxTotal = money(quote['servicesTaxTotal'], 'Services tax total'); + const grandTotal = money(quote['grandTotal'], 'Grand total'); + 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 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); + if (input.source !== 'custom') { + return { version: 1, source: input.source, ...normalized, 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'); + } + custom = custom.toDecimalPlaces(2); + const adjustment = custom.minus(normalized['grandTotal']).toDecimalPlaces(2); + return { + version: 1, + source: 'custom', + ...normalized, + grandTotal: custom.toFixed(2), + 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.module.ts b/apps/api/src/modules/booking-request/booking-request.module.ts index c9d53fc5..d99fa0e1 100644 --- a/apps/api/src/modules/booking-request/booking-request.module.ts +++ b/apps/api/src/modules/booking-request/booking-request.module.ts @@ -13,6 +13,7 @@ import { WebhookModule } from '../webhook/webhook.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'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { BookingRequestService } from './booking-request.service'; controllers: [BookingRequestPublicController, BookingRequestController], providers: [ BookingRequestService, + BookingRequestConsequenceWorkerService, BookingKeyGuard, BookingEngineScopeGuard, BookingThrottleGuard, diff --git a/apps/api/src/modules/booking-request/booking-request.service.ts b/apps/api/src/modules/booking-request/booking-request.service.ts index 075e850a..ff64ee55 100644 --- a/apps/api/src/modules/booking-request/booking-request.service.ts +++ b/apps/api/src/modules/booking-request/booking-request.service.ts @@ -17,6 +17,7 @@ import { reservations, } from '@telivityhaip/database'; import type { + AcceptedPricingSnapshot, BookingFormQuestion, PaymentMethodCollection, } from '@telivityhaip/database'; @@ -40,6 +41,7 @@ import { } from '../../common/audit/audit-actor'; 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 { @@ -63,15 +65,21 @@ import { import { assertCanonicalStayDates } from './booking-request-date.validator'; import { assertDenialMoneyResolved, - resolveAcceptedTotal, type BookingRequestPriceSource, } from './booking-request-money'; import { assertBookingRequestTransition } from './booking-request-state'; +import { buildAcceptedPricingSnapshot } from './booking-request-pricing'; 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 { + toAcceptedBookingRequestDecision, + toBookingRequestDetail, + toBookingRequestListItem, + toDeniedBookingRequestDecision, +} from './dto/booking-request-response.dto'; export type AcceptBookingRequestInput = { priceSource: BookingRequestPriceSource; @@ -195,13 +203,59 @@ export class BookingRequestService { ]); // 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); + 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) { - return this.findRequest(this.db, id, propertyId); + return toBookingRequestDetail(await this.findRequest(this.db, id, propertyId)); + } + + /** + * 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( @@ -214,34 +268,18 @@ export class BookingRequestService { if (initial.status === 'accepted') { const linked = await this.findLinkedReservation(this.db, initial, propertyId); await this.deliverConsequencesBestEffort(id, propertyId); - return linked; + return toAcceptedBookingRequestDecision(initial, linked); } if (initial.status === 'denied') { throw new ConflictException('Cannot accept a denied booking request'); } - const currentQuote = await this.bookingEngineService.quote(propertyId, { - roomTypeId: initial.roomTypeId, - ratePlanId: initial.ratePlanId, - checkIn: initial.arrivalDate, - checkOut: initial.departureDate, - adults: initial.adults, - children: initial.children, - serviceIds: initial.serviceIds, - }).catch((error: unknown) => this.throwAcceptanceError(error)); - const preliminaryPrice = resolveAcceptedTotal({ - source: input.priceSource, - submittedTotal: this.quoteTotal(initial.submittedQuoteSnapshot), - currentTotal: currentQuote.grandTotal, - customTotal: input.customTotal, - customReason: input.customReason, - }); - 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') { @@ -249,10 +287,21 @@ export class BookingRequestService { } assertBookingRequestTransition(locked.status, 'accepted'); - const price = resolveAcceptedTotal({ - source: preliminaryPrice.source, - submittedTotal: this.quoteTotal(locked.submittedQuoteSnapshot), - currentTotal: currentQuote.grandTotal, + 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, }); @@ -269,34 +318,56 @@ export class BookingRequestService { departureDate: locked.departureDate, roomTypeId: locked.roomTypeId, ratePlanId: locked.ratePlanId, - totalAmount: price.total.toFixed(2), - currencyCode: locked.currencyCode, + totalAmount: pricing.grandTotal, + currencyCode: pricing.currencyCode, adults: locked.adults, children: locked.children, specialRequests: locked.specialRequests ?? undefined, source: 'direct', channelCode: 'booking_request', - }, undefined, tx); + }, { acceptedPricingSnapshot: pricing }, tx); const folio = await this.folioService.createAutoFolio({ id: reservation.id, propertyId, bookingId: reservation.bookingId, guestId: guest.id, - currencyCode: locked.currencyCode, + 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' }, + { + 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 @@ -317,9 +388,9 @@ export class BookingRequestService { .set({ status: 'accepted', currentQuoteSnapshot: structuredClone(currentQuote), - acceptedPriceSource: price.source, - acceptedTotal: price.total.toFixed(2), - customPriceReason: price.customReason ?? null, + acceptedPriceSource: pricing.source, + acceptedTotal: pricing.grandTotal, + customPriceReason: pricing.adjustment?.reason ?? null, acceptedReservationId: reservation.id, acceptedFolioId: folio.id, decidedBy: actor?.userId ?? null, @@ -345,8 +416,8 @@ export class BookingRequestService { requestId: id, reservationId: reservation.id, folioId: folio.id, - priceSource: price.source, - acceptedTotal: price.total.toFixed(2), + priceSource: pricing.source, + acceptedTotal: pricing.grandTotal, }, timestamp: decidedAt.toISOString(), }); @@ -379,15 +450,20 @@ export class BookingRequestService { }); 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: { - reservationId: reservation.id, - serviceId: attached['serviceId'] ?? null, - }, + data: reservationServiceAttachedPayload( + attached as unknown as Parameters< + typeof reservationServiceAttachedPayload + >[0], + attached['serviceName'], + ), timestamp: decidedAt.toISOString(), }); } @@ -402,17 +478,17 @@ export class BookingRequestService { status: 'accepted', reservationId: reservation.id, folioId: folio.id, - priceSource: price.source, - acceptedTotal: price.total.toFixed(2), - customPriceReason: price.customReason ?? null, + priceSource: pricing.source, + acceptedTotal: pricing.grandTotal, + customPriceReason: pricing.adjustment?.reason ?? null, }, description: 'Booking request accepted', }); - return { reservation }; + return { reservation, request: updated }; }).catch((error: unknown) => this.throwAcceptanceError(error)); await this.deliverConsequencesBestEffort(id, propertyId); - return result.reservation; + return toAcceptedBookingRequestDecision(result.request, result.reservation); } async deny( @@ -426,6 +502,9 @@ export class BookingRequestService { const denied = await this.db.transaction(async (tx) => { const locked = await this.lockRequest(tx, id, propertyId); + if (locked.status === 'denied') { + return locked; + } assertBookingRequestTransition(locked.status, 'denied'); const movementRows = await tx @@ -491,7 +570,7 @@ export class BookingRequestService { }); await this.deliverConsequencesBestEffort(id, propertyId); - return denied; + return toDeniedBookingRequestDecision(denied); } async createPaymentMethodSetup( @@ -920,12 +999,6 @@ export class BookingRequestService { return reservation; } - private quoteTotal(snapshot: unknown): string | null { - if (!snapshot || typeof snapshot !== 'object') return null; - const total = (snapshot as Record)['grandTotal']; - return typeof total === 'string' ? total : null; - } - private throwAcceptanceError(error: unknown): never { if ( error instanceof BadRequestException 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..5046ac9b --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/booking-request-response.dto.ts @@ -0,0 +1,137 @@ +import type { bookingRequests, reservations } from '@telivityhaip/database'; + +type BookingRequestRow = typeof bookingRequests.$inferSelect; +type ReservationRow = typeof reservations.$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; + 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; +} + +export interface AcceptedBookingRequestDecisionDto { + requestId: string; + status: 'accepted'; + reservationId: string; + folioId: string | null; + totalAmount: string; + currencyCode: string; +} + +export interface DeniedBookingRequestDecisionDto { + requestId: string; + status: 'denied'; + denialReason: string; + decidedAt: Date | null; +} + +export function toBookingRequestListItem( + row: BookingRequestRow, +): BookingRequestListItemDto { + 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), + acceptedPriceSource: row.acceptedPriceSource, + acceptedTotal: row.acceptedTotal, + acceptedReservationId: row.acceptedReservationId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function toBookingRequestDetail( + row: BookingRequestRow, +): 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, + currencyCode: row.currencyCode, + 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, + }; +} + +export function toAcceptedBookingRequestDecision( + request: Pick< + BookingRequestRow, + 'id' | 'acceptedFolioId' | 'acceptedTotal' | 'currencyCode' + >, + 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, + }; +} + +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/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/folio/folio.service.spec.ts b/apps/api/src/modules/folio/folio.service.spec.ts index 1833c949..a4f4a607 100644 --- a/apps/api/src/modules/folio/folio.service.spec.ts +++ b/apps/api/src/modules/folio/folio.service.spec.ts @@ -364,6 +364,56 @@ 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, + ) => ({ + id: `charge-${dto.type}`, + ...dto, + 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); + }); + }); + describe('reverseCharge', () => { it('should create a negated charge for reversal', async () => { const reversalCharge = { diff --git a/apps/api/src/modules/folio/folio.service.ts b/apps/api/src/modules/folio/folio.service.ts index 105a915f..05ed0247 100644 --- a/apps/api/src/modules/folio/folio.service.ts +++ b/apps/api/src/modules/folio/folio.service.ts @@ -386,15 +386,101 @@ export class FolioService { await this.recalculateBalance(folioId, dto.propertyId, tx); + 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 }; + } + + /** 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 }, + ) { + const result = await this.db.transaction(async (tx: any) => { + const base = await this.postCharge(folioId, { + ...dto, + skipTaxCalculation: true, + }, tx); + const taxCharges: any[] = []; + if (new Decimal(taxAmount).greaterThan(0)) { + taxCharges.push(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)); + } + const adjustmentCharges: any[] = []; + if (adjustment && !new Decimal(adjustment.amount).isZero()) { + adjustmentCharges.push(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)); + } + return { ...base, taxCharges, adjustmentCharges }; + }); + await this.webhookService.emit( 'folio.charge_posted', 'charge', - charge.id, - { folioId, type: charge.type, amount: charge.amount, description: charge.description }, + result.id, + { + folioId, + type: result.type, + amount: result.amount, + description: result.description, + }, dto.propertyId, ); - - return { ...charge, taxCharges }; + for (const tax of result.taxCharges) { + await this.webhookService.emit( + 'folio.charge_posted', + 'charge', + tax.id, + { + folioId, + type: tax.type, + amount: tax.amount, + description: tax.description, + }, + dto.propertyId, + ); + } + for (const adjustmentCharge of result.adjustmentCharges) { + await this.webhookService.emit( + 'folio.charge_posted', + 'charge', + adjustmentCharge.id, + { + folioId, + type: adjustmentCharge.type, + amount: adjustmentCharge.amount, + description: adjustmentCharge.description, + }, + dto.propertyId, + ); + } + return result; } async reverseCharge(folioId: string, chargeId: string, propertyId: string) { 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..191ba035 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,11 @@ import { PolicyService } from '../policy/policy.service'; import { DepositSettlementService } from '../accounting/deposit-settlement.service'; const mockFolioService = { + 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', @@ -338,6 +343,119 @@ 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], + [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.postChargeFromSnapshot).toHaveBeenCalledWith( + 'folio-001', + expect.objectContaining({ type: 'room', amount: '123.00' }), + '12.00', + undefined, + ); + expect(mockFolioService.postCharge).not.toHaveBeenCalled(); + expect(result).toMatchObject({ totalRoom: '123.00', totalTax: '12.00', count: 1 }); + }); + + 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], [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.postChargeFromSnapshot).toHaveBeenCalledWith( + 'folio-001', + expect.objectContaining({ type: 'room', amount: '123.00' }), + '12.00', + { + amount: '-15.00', + reason: 'Staff loyalty adjustment', + }, + ); + }); + 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..700c958e 100644 --- a/apps/api/src/modules/night-audit/night-audit.service.ts +++ b/apps/api/src/modules/night-audit/night-audit.service.ts @@ -204,31 +204,55 @@ export class NightAuditService { continue; // Already posted, skip } - // Get nightly rate from rate plan or fallback + const acceptedPricing = reservation.acceptedPricingSnapshot; + const acceptedNight = acceptedPricing?.nights?.find( + (night: { date: string }) => night.date === businessDate, + ); 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; + let result: any; + if (acceptedPricing) { + if (!acceptedNight) continue; + rate = acceptedNight.roomAmount; + const acceptedAdjustment = acceptedPricing.adjustment?.serviceDate === businessDate + ? { + amount: acceptedPricing.adjustment.amount, + reason: acceptedPricing.adjustment.reason, + } + : undefined; + result = await this.folioService.postChargeFromSnapshot( + folio.id, + { + propertyId, + type: 'room', + description: `Room tariff - ${businessDate}`, + amount: rate, + currencyCode: acceptedPricing.currencyCode, + serviceDate: serviceDateStart.toISOString(), + guestId: reservation.guestId, + }, + acceptedNight.taxAmount, + acceptedAdjustment, + ); } else { - // Fallback: total / nights - rate = new Decimal(reservation.totalAmount).div(reservation.nights).toFixed(2); + // Existing reservations retain canonical live rate/tax behavior. + const [ratePlan] = await this.db + .select({ baseAmount: ratePlans.baseAmount }) + .from(ratePlans) + .where(eq(ratePlans.id, reservation.ratePlanId)); + rate = ratePlan + ? ratePlan.baseAmount + : new Decimal(reservation.totalAmount).div(reservation.nights).toFixed(2); + result = await this.folioService.postCharge(folio.id, { + propertyId, + type: 'room', + description: `Room tariff - ${businessDate}`, + amount: rate, + currencyCode: reservation.currencyCode, + serviceDate: serviceDateStart.toISOString(), + guestId: reservation.guestId, + }); } - // Post room tariff — TaxService auto-posts tax charges via FolioService - const result = await this.folioService.postCharge(folio.id, { - propertyId, - type: 'room', - description: `Room tariff - ${businessDate}`, - amount: rate, - currencyCode: reservation.currencyCode, - serviceDate: serviceDateStart.toISOString(), - guestId: reservation.guestId, - }); - // Sum auto-posted tax charges via Decimal const taxAmountDec = (result.taxCharges ?? []) .reduce((sum: Decimal, tc: any) => sum.plus(new Decimal(tc.amount)), new Decimal(0)); 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 483d9f1b..84582159 100644 --- a/apps/api/src/modules/rate-plan/rate-plan.service.ts +++ b/apps/api/src/modules/rate-plan/rate-plan.service.ts @@ -220,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`); } @@ -293,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); @@ -329,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, @@ -353,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`, @@ -377,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/reservation/availability.service.ts b/apps/api/src/modules/reservation/availability.service.ts index b01a6a84..6c4f5ffa 100644 --- a/apps/api/src/modules/reservation/availability.service.ts +++ b/apps/api/src/modules/reservation/availability.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from '@nestjs/common'; +import { BadRequestException, Injectable, Inject } from '@nestjs/common'; import { eq, and, 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) {} @@ -30,6 +79,7 @@ export class AvailabilityService { db?: any, ): Promise { const conn = db ?? this.db; + const requestedDates = stayDates(checkIn, checkOut); // Get property overbooking config const [property] = await conn @@ -122,20 +172,12 @@ 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( 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..5381f2e8 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,18 @@ 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'; 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 +40,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 +57,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 +143,131 @@ 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('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.service.ts b/apps/api/src/modules/reservation/reservation.service.ts index 62cf5b4e..b357d27b 100644 --- a/apps/api/src/modules/reservation/reservation.service.ts +++ b/apps/api/src/modules/reservation/reservation.service.ts @@ -11,7 +11,10 @@ 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, +} from './availability.service'; import { FolioService } from '../folio/folio.service'; import { RoomStatusService } from '../room/room-status.service'; import { PaymentService } from '../payment/payment.service'; @@ -32,7 +35,9 @@ 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'; @Injectable() export class ReservationService { @@ -52,7 +57,10 @@ export class ReservationService { async create( dto: CreateReservationDto, - opts?: { confirmationNumber?: string }, + opts?: { + confirmationNumber?: string; + acceptedPricingSnapshot?: AcceptedPricingSnapshot; + }, tx?: any, ) { const db = tx ?? this.db; @@ -80,12 +88,9 @@ 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 @@ -125,12 +130,16 @@ export class ReservationService { ); } - // 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. + // 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, @@ -139,12 +148,12 @@ export class ReservationService { dto.roomTypeId, transaction, ); - 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`, - ); - } + assertFullStayAvailability( + availability, + dto.roomTypeId, + dto.arrivalDate, + dto.departureDate, + ); const [booking] = await transaction .insert(bookings) @@ -171,6 +180,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, @@ -211,6 +221,20 @@ export class ReservationService { 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. @@ -1094,17 +1118,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. + // Use the same room-type inventory mutex as canonical creation so a modify + // cannot race another create/modify for the final unit. const updated = 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, @@ -1120,21 +1143,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; } } 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/packages/database/src/booking-request-schema.spec.ts b/packages/database/src/booking-request-schema.spec.ts index 17370c2f..3157e2fd 100644 --- a/packages/database/src/booking-request-schema.spec.ts +++ b/packages/database/src/booking-request-schema.spec.ts @@ -6,6 +6,7 @@ import { bookingRequests, bookingRequestInstallments, payments, + reservations, webhookDeliveries, } from './schema/index.js'; @@ -31,6 +32,7 @@ describe('booking request schema', () => { expect(payments.bookingRequestId).toBeDefined(); expect(payments.idempotencyKey).toBeDefined(); expect(webhookDeliveries.logicalEventId).toBeDefined(); + expect(reservations.acceptedPricingSnapshot).toBeDefined(); const indexNames = getTableConfig(bookingRequests).indexes.map((index) => index.config.name); expect(indexNames).toContain('booking_requests_property_submission_key_unique'); diff --git a/packages/database/src/migrations/0022_booking_request_accepted_pricing.sql b/packages/database/src/migrations/0022_booking_request_accepted_pricing.sql new file mode 100644 index 00000000..8864c932 --- /dev/null +++ b/packages/database/src/migrations/0022_booking_request_accepted_pricing.sql @@ -0,0 +1,5 @@ +-- Freeze the operational tariff chosen during Booking Request acceptance. +-- Existing reservations remain NULL and continue using their canonical live +-- rate-plan/night-audit behavior. +ALTER TABLE reservations + ADD COLUMN IF NOT EXISTS accepted_pricing_snapshot jsonb; diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index 0b191f36..ea1e1cd7 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -317,6 +317,7 @@ async function main() { updated_at timestamptz NOT NULL DEFAULT now() )`, `ALTER TABLE reservations ADD COLUMN IF NOT EXISTS do_not_move boolean NOT NULL DEFAULT false`, + `ALTER TABLE reservations ADD COLUMN IF NOT EXISTS accepted_pricing_snapshot jsonb`, // folios `CREATE TABLE IF NOT EXISTS folios ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 53bb8c3e..5833830a 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -53,6 +53,10 @@ export { reservations, reservationGuests, reservationNotes, + type AcceptedPricingNight, + type AcceptedPricingServiceNight, + type AcceptedPricingService, + type AcceptedPricingSnapshot, } from './reservation.js'; // Cancellation policies (rate-plan money outcomes) diff --git a/packages/database/src/schema/reservation.ts b/packages/database/src/schema/reservation.ts index 87461f9f..a0bb5079 100644 --- a/packages/database/src/schema/reservation.ts +++ b/packages/database/src/schema/reservation.ts @@ -36,6 +36,51 @@ export const bookingSourceEnum = pgEnum('booking_source', [ 'corporate', // Corporate portal ]); +export interface AcceptedPricingNight { + date: string; + roomAmount: string; + taxAmount: string; +} + +export interface AcceptedPricingServiceNight { + date: string; + amount: string; + taxAmount: string; +} + +export interface AcceptedPricingService { + serviceId: string; + code: string; + name: string; + postingRule: string; + chargeType: string; + currencyCode: string; + unitPrice: string; + quantity: number; + lineTotal: string; + taxTotal: string; + lineItems: AcceptedPricingServiceNight[]; +} + +/** Immutable operational tariff chosen when staff accepts a Booking Request. */ +export interface AcceptedPricingSnapshot { + version: 1; + source: 'submitted' | 'current' | 'custom'; + currencyCode: string; + grandTotal: string; + roomTotal: string; + taxTotal: string; + nights: AcceptedPricingNight[]; + services: AcceptedPricingService[]; + servicesTotal: string; + servicesTaxTotal: string; + adjustment: null | { + amount: string; + reason: string; + serviceDate: string; + }; +} + /** * Bookings — container for one or more reservations; identifies the booker. * Booking is the party wrapper; reservations are per-room. @@ -92,6 +137,7 @@ export const reservations = pgTable('reservations', { ratePlanId: uuid('rate_plan_id').notNull().references(() => ratePlans.id), totalAmount: numeric('total_amount', { precision: 12, scale: 2 }).notNull(), currencyCode: varchar('currency_code', { length: 3 }).notNull(), + acceptedPricingSnapshot: jsonb('accepted_pricing_snapshot').$type(), // Occupancy adults: integer('adults').notNull().default(1), From f729e33d0fa9675a2d0d60bb42a99411ae97647e Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 19:42:53 +0200 Subject: [PATCH 18/87] fix(booking-requests): preserve accepted pricing integrity --- .../ancillary-accepted-pricing.spec.ts | 64 ++++ .../modules/ancillary/ancillary.service.ts | 4 + .../booking-request-decision.spec.ts | 48 +++ .../booking-request-pricing.spec.ts | 19 ++ .../booking-request-pricing.ts | 14 +- .../booking-request.service.ts | 4 +- .../dto/booking-request-response.dto.ts | 11 +- .../src/modules/folio/folio.service.spec.ts | 209 ++++++++++++++ apps/api/src/modules/folio/folio.service.ts | 273 +++++++++++------- .../reservation-assert-sellable.spec.ts | 77 +++++ .../reservation/reservation.service.ts | 22 ++ .../booking-request-migration-safety.spec.ts | 36 +++ .../src/booking-request-schema.spec.ts | 5 + .../0022_booking_request_accepted_pricing.sql | 136 +++++++++ packages/database/src/push-schema.ts | 120 ++++++++ packages/database/src/schema/folio.ts | 8 +- packages/database/src/schema/reservation.ts | 1 + 17 files changed, 947 insertions(+), 104 deletions(-) create mode 100644 packages/database/src/booking-request-migration-safety.spec.ts diff --git a/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts b/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts index efeba4e5..d71219b1 100644 --- a/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts +++ b/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts @@ -18,6 +18,68 @@ function stagedSelect(stages: any[][]) { } describe('AncillaryService accepted operational pricing', () => { + 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-10-01', + acceptedPricingSnapshot: { + currencyCode: 'EUR', + services: [{ + serviceId: 'svc-1', + 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 = { + 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(), + postChargeFromSnapshot: vi.fn().mockResolvedValue({ id: 'charge-1' }), + }; + const service = new AncillaryService( + db as any, + folio as any, + { emit: vi.fn() } as any, + ); + + await service.postOnceForReservation('res-1', 'prop-1'); + + expect(folio.postChargeFromSnapshot).toHaveBeenCalledWith( + 'folio-1', + expect.objectContaining({ amount: '15.00', currencyCode: 'EUR' }), + '2.00', + undefined, + 'accepted-pricing:reservation-service:rs-1:once', + ); + }); + it('posts the frozen per-night service and tax instead of live catalog pricing', async () => { const reservation = { id: 'res-1', @@ -68,6 +130,8 @@ describe('AncillaryService accepted operational pricing', () => { 'folio-1', expect.objectContaining({ amount: '15.00', currencyCode: 'EUR' }), '2.00', + undefined, + 'accepted-pricing:reservation-service:rs-1:night:2026-10-02', ); expect(folio.postCharge).not.toHaveBeenCalled(); expect(result.posted).toEqual([ diff --git a/apps/api/src/modules/ancillary/ancillary.service.ts b/apps/api/src/modules/ancillary/ancillary.service.ts index 12202001..6755d82e 100644 --- a/apps/api/src/modules/ancillary/ancillary.service.ts +++ b/apps/api/src/modules/ancillary/ancillary.service.ts @@ -533,6 +533,8 @@ export class AncillaryService { folio.id, chargeInput, acceptedLine.taxAmount, + undefined, + `accepted-pricing:reservation-service:${rs.id}:once`, ); } else { await this.folioService.postCharge(folio.id, chargeInput); @@ -661,6 +663,8 @@ export class AncillaryService { folio.id, chargeInput, acceptedLine.taxAmount, + undefined, + `accepted-pricing:reservation-service:${rs.id}:night:${date}`, ) : await this.folioService.postCharge(folio.id, chargeInput); 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 index 52536682..63371464 100644 --- a/apps/api/src/modules/booking-request/booking-request-decision.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-decision.spec.ts @@ -624,6 +624,8 @@ describe('BookingRequestService acceptance', () => { folioId: FOLIO_ID, totalAmount: expectedTotal, currencyCode: 'EUR', + priceSource, + customReason: customReason ?? null, }); expect(harness.state.requests[0]).toMatchObject({ status: 'accepted', @@ -674,6 +676,48 @@ describe('BookingRequestService acceptance', () => { expect(harness.state.guests).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', + }, + 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); @@ -750,6 +794,8 @@ describe('BookingRequestService acceptance', () => { folioId: FOLIO_ID, totalAmount: '220.00', currencyCode: 'EUR', + priceSource: 'submitted', + customReason: null, }); expect(harness.reservationCreates).toBe(1); expect(harness.state.reservations).toHaveLength(1); @@ -844,6 +890,8 @@ describe('BookingRequestService acceptance', () => { folioId: FOLIO_ID, totalAmount: '220.00', currencyCode: 'EUR', + priceSource: 'submitted', + customReason: null, }); expect(harness.reservationCreates).toBe(0); }); 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 index 79639caa..c64447b6 100644 --- a/apps/api/src/modules/booking-request/booking-request-pricing.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-pricing.spec.ts @@ -90,6 +90,7 @@ describe('buildAcceptedPricingSnapshot', () => { { date: '2026-10-02', amount: '15.00', taxAmount: '2.00' }, ], }], + customReason: null, adjustment: null, }); }); @@ -114,6 +115,24 @@ describe('buildAcceptedPricingSnapshot', () => { 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', diff --git a/apps/api/src/modules/booking-request/booking-request-pricing.ts b/apps/api/src/modules/booking-request/booking-request-pricing.ts index f6658b76..a9b72975 100644 --- a/apps/api/src/modules/booking-request/booking-request-pricing.ts +++ b/apps/api/src/modules/booking-request/booking-request-pricing.ts @@ -63,7 +63,10 @@ function normalizeQuote( quote: QuoteRecord, source: BookingRequestPriceSource, requestCurrencyCode: string, -): Omit { +): 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'); @@ -204,7 +207,13 @@ export function buildAcceptedPricingSnapshot( const basis = input.source === 'submitted' ? submitted : current; const normalized = normalizeQuote(basis, input.source, input.requestCurrencyCode); if (input.source !== 'custom') { - return { version: 1, source: input.source, ...normalized, adjustment: null }; + return { + version: 1, + source: input.source, + ...normalized, + customReason: null, + adjustment: null, + }; } const reason = input.customReason?.trim(); @@ -227,6 +236,7 @@ export function buildAcceptedPricingSnapshot( source: 'custom', ...normalized, grandTotal: custom.toFixed(2), + customReason: reason, adjustment: adjustment.isZero() ? null : { diff --git a/apps/api/src/modules/booking-request/booking-request.service.ts b/apps/api/src/modules/booking-request/booking-request.service.ts index ff64ee55..d2b187ce 100644 --- a/apps/api/src/modules/booking-request/booking-request.service.ts +++ b/apps/api/src/modules/booking-request/booking-request.service.ts @@ -390,7 +390,7 @@ export class BookingRequestService { currentQuoteSnapshot: structuredClone(currentQuote), acceptedPriceSource: pricing.source, acceptedTotal: pricing.grandTotal, - customPriceReason: pricing.adjustment?.reason ?? null, + customPriceReason: pricing.customReason, acceptedReservationId: reservation.id, acceptedFolioId: folio.id, decidedBy: actor?.userId ?? null, @@ -480,7 +480,7 @@ export class BookingRequestService { folioId: folio.id, priceSource: pricing.source, acceptedTotal: pricing.grandTotal, - customPriceReason: pricing.adjustment?.reason ?? null, + customPriceReason: pricing.customReason, }, description: 'Booking request accepted', }); 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 index 5046ac9b..38023066 100644 --- 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 @@ -48,6 +48,8 @@ export interface AcceptedBookingRequestDecisionDto { folioId: string | null; totalAmount: string; currencyCode: string; + priceSource: BookingRequestRow['acceptedPriceSource']; + customReason: string | null; } export interface DeniedBookingRequestDecisionDto { @@ -111,7 +113,12 @@ export function toBookingRequestDetail( export function toAcceptedBookingRequestDecision( request: Pick< BookingRequestRow, - 'id' | 'acceptedFolioId' | 'acceptedTotal' | 'currencyCode' + | 'id' + | 'acceptedFolioId' + | 'acceptedTotal' + | 'currencyCode' + | 'acceptedPriceSource' + | 'customPriceReason' >, reservation: Pick, ): AcceptedBookingRequestDecisionDto { @@ -122,6 +129,8 @@ export function toAcceptedBookingRequestDecision( folioId: request.acceptedFolioId, totalAmount: request.acceptedTotal ?? reservation.totalAmount, currencyCode: reservation.currencyCode ?? request.currencyCode, + priceSource: request.acceptedPriceSource, + customReason: request.customPriceReason, }; } diff --git a/apps/api/src/modules/folio/folio.service.spec.ts b/apps/api/src/modules/folio/folio.service.spec.ts index a4f4a607..51937e5a 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; @@ -377,9 +391,12 @@ describe('FolioService', () => { 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: [], })); @@ -411,6 +428,108 @@ describe('FolioService', () => { 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 results = await Promise.all([ + (svc.postChargeFromSnapshot as any)( + 'folio-001', input, '2.00', undefined, sourceKey, + ), + (svc.postChargeFromSnapshot as any)( + 'folio-001', input, '2.00', undefined, sourceKey, + ), + ]); + + expect(ledger.map((row) => row.type)).toEqual(['parking', 'tax']); + expect(results[0].id).toBe(results[1].id); + expect(results[0].taxCharges).toEqual(results[1].taxCharges); + expect(webhook.emit).toHaveBeenCalledTimes(2); }); }); @@ -505,6 +624,96 @@ 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'), + }; + 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, + }; + const inserted: Array> = []; + 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 (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); + }); }); describe('close', () => { diff --git a/apps/api/src/modules/folio/folio.service.ts b/apps/api/src/modules/folio/folio.service.ts index 05ed0247..fb9a3c4b 100644 --- a/apps/api/src/modules/folio/folio.service.ts +++ b/apps/api/src/modules/folio/folio.service.ts @@ -3,6 +3,7 @@ import { Inject, NotFoundException, BadRequestException, + ConflictException, } from '@nestjs/common'; import { eq, and, sql, gte, lte } from 'drizzle-orm'; import Decimal from 'decimal.js'; @@ -18,6 +19,8 @@ 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'); + @Injectable() export class FolioService { constructor( @@ -286,7 +289,12 @@ export class FolioService { .where(and(eq(folios.id, folioId), eq(folios.propertyId, propertyId))); } - async postCharge(folioId: string, dto: CreateChargeDto, tx?: any) { + async postCharge( + folioId: string, + dto: CreateChargeDto, + tx?: any, + persistence?: { parentChargeId?: string; sourceKey?: string }, + ) { const db = tx ?? this.db; const folio = await this.findById(folioId, dto.propertyId, tx); if (folio.status !== 'open') { @@ -332,7 +340,7 @@ export class FolioService { } } - const [charge] = await db + const insert = db .insert(charges) .values({ propertyId: dto.propertyId, @@ -347,9 +355,33 @@ export class FolioService { serviceDate: new Date(dto.serviceDate), isReversal: dto.isReversal ?? false, originalChargeId: dto.originalChargeId, + 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[] = []; @@ -396,7 +428,9 @@ export class FolioService { ); } - return { ...charge, taxCharges }; + const result = { ...charge, taxCharges }; + Object.defineProperty(result, CHARGE_WAS_CREATED, { value: true }); + return result; } /** Post an immutable accepted base/tax pair atomically without live tax lookup. */ @@ -405,15 +439,33 @@ export class FolioService { dto: CreateChargeDto, taxAmount: string, adjustment?: { amount: string; reason: string }, + sourceKey?: string, ) { const result = await this.db.transaction(async (tx: any) => { const base = await this.postCharge(folioId, { ...dto, skipTaxCalculation: true, - }, tx); + }, 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)) { - taxCharges.push(await this.postCharge(folioId, { + const frozenTax = await this.postCharge(folioId, { propertyId: dto.propertyId, type: 'tax', description: `${dto.description} tax`.slice(0, 255), @@ -422,11 +474,14 @@ export class FolioService { serviceDate: dto.serviceDate, postedBy: dto.postedBy, skipTaxCalculation: true, - }, tx)); + }, tx, { parentChargeId: base.id }); + const { taxCharges: _nestedTaxes, ...taxCharge } = frozenTax; + void _nestedTaxes; + taxCharges.push(taxCharge); } const adjustmentCharges: any[] = []; if (adjustment && !new Decimal(adjustment.amount).isZero()) { - adjustmentCharges.push(await this.postCharge(folioId, { + const frozenAdjustment = await this.postCharge(folioId, { propertyId: dto.propertyId, type: 'adjustment', description: `Accepted price adjustment: ${adjustment.reason}`.slice(0, 255), @@ -435,11 +490,20 @@ export class FolioService { serviceDate: dto.serviceDate, postedBy: dto.postedBy, skipTaxCalculation: true, - }, tx)); + }, tx, { parentChargeId: base.id }); + const { taxCharges: _nestedTaxes, ...adjustmentCharge } = frozenAdjustment; + void _nestedTaxes; + adjustmentCharges.push(adjustmentCharge); } - return { ...base, taxCharges, adjustmentCharges }; + return { ...base, taxCharges, adjustmentCharges, wasCreated: true }; }); + if (!result.wasCreated) { + const { wasCreated: _wasCreated, ...existing } = result; + void _wasCreated; + return existing; + } + await this.webhookService.emit( 'folio.charge_posted', 'charge', @@ -480,110 +544,123 @@ export class FolioService { dto.propertyId, ); } - return result; + const { wasCreated: _wasCreated, ...posted } = result; + void _wasCreated; + return posted; } 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'); - } - - // 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'); - } - - const negatedAmount = new Decimal(original.amount).negated().toFixed(2); - const negatedTax = new Decimal(original.taxAmount).negated().toFixed(2); - - const [reversal] = await this.db - .insert(charges) - .values({ - 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), - ), - ); + const reverseInTransaction = async (db: any) => { + const originalQuery = db + .select() + .from(charges) + .where( + 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'); + } - for (const taxCharge of childTaxCharges) { - // Check not already reversed - const [existingTaxReversal] = await this.db + // The original row lock serializes competing whole-group reversals. + const [existing] = await db .select() .from(charges) .where( - and(eq(charges.originalChargeId, taxCharge.id), eq(charges.isReversal, true)), + and( + eq(charges.originalChargeId, chargeId), + eq(charges.isReversal, true), + ), ); - 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), + ), + ); + 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)), + ); + 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', 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 5381f2e8..4035c029 100644 --- a/apps/api/src/modules/reservation/reservation-assert-sellable.spec.ts +++ b/apps/api/src/modules/reservation/reservation-assert-sellable.spec.ts @@ -186,6 +186,83 @@ describe('ReservationService.create — assertSellable (BOOK path)', () => { } 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({ + specialRequests: 'Late arrival', + doNotMove: true, + acceptedPricingSnapshot: expect.any(Object), + }); + expect(set).toHaveBeenCalledWith(expect.objectContaining({ + specialRequests: 'Late arrival', + doNotMove: true, + })); + }); + it('serializes two canonical creates competing for the final room', async () => { let reservationCount = 0; let bookingCount = 0; diff --git a/apps/api/src/modules/reservation/reservation.service.ts b/apps/api/src/modules/reservation/reservation.service.ts index b357d27b..a043f2cb 100644 --- a/apps/api/src/modules/reservation/reservation.service.ts +++ b/apps/api/src/modules/reservation/reservation.service.ts @@ -1067,6 +1067,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)) { diff --git a/packages/database/src/booking-request-migration-safety.spec.ts b/packages/database/src/booking-request-migration-safety.spec.ts new file mode 100644 index 00000000..5c2287aa --- /dev/null +++ b/packages/database/src/booking-request-migration-safety.spec.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const migration = readFileSync( + new URL('./migrations/0022_booking_request_accepted_pricing.sql', import.meta.url), + 'utf8', +); +const pushSchema = readFileSync(new URL('./push-schema.ts', import.meta.url), 'utf8'); + +describe('booking request accepted-pricing migration safety', () => { + it('fails instead of accepting an already-accepted request without an operational snapshot', () => { + for (const source of [migration, pushSchema]) { + expect(source).toContain('booking_request_accepted_snapshot_precondition'); + expect(source).toMatch(/status\s*=\s*'accepted'/); + expect(source).toMatch(/accepted_pricing_snapshot\s+IS\s+NULL/i); + expect(source).toMatch(/RAISE EXCEPTION[^;]*accepted Booking Request/i); + } + }); + + it('fails on a pending submitted quote that cannot be losslessly normalized', () => { + for (const source of [migration, pushSchema]) { + expect(source).toContain('booking_request_submitted_quote_precondition'); + expect(source).toMatch(/status\s*=\s*'pending'/); + expect(source).toContain("jsonb_typeof(submitted_quote_snapshot -> 'lineItems') = 'array'"); + expect(source).toContain("jsonb_typeof(submitted_quote_snapshot -> 'services') = 'array'"); + expect(source).toMatch(/RAISE EXCEPTION[^;]*submitted quote snapshot/i); + } + }); + + it('adds the nullable namespaced charge source key before its scoped unique index', () => { + const column = migration.indexOf('ADD COLUMN IF NOT EXISTS source_key'); + const index = migration.indexOf('charges_property_folio_source_key_unique'); + expect(column).toBeGreaterThanOrEqual(0); + expect(index).toBeGreaterThan(column); + }); +}); diff --git a/packages/database/src/booking-request-schema.spec.ts b/packages/database/src/booking-request-schema.spec.ts index 3157e2fd..7d848a5f 100644 --- a/packages/database/src/booking-request-schema.spec.ts +++ b/packages/database/src/booking-request-schema.spec.ts @@ -5,6 +5,7 @@ import { bookingRequestConsequences, bookingRequests, bookingRequestInstallments, + charges, payments, reservations, webhookDeliveries, @@ -31,6 +32,7 @@ describe('booking request schema', () => { expect(bookingEngineConfig.paymentMethodCollection).toBeDefined(); expect(payments.bookingRequestId).toBeDefined(); expect(payments.idempotencyKey).toBeDefined(); + expect(charges.sourceKey).toBeDefined(); expect(webhookDeliveries.logicalEventId).toBeDefined(); expect(reservations.acceptedPricingSnapshot).toBeDefined(); @@ -49,5 +51,8 @@ describe('booking request schema', () => { expect(deliveryIndexNames).toContain( 'webhook_deliveries_property_subscription_logical_event_unique', ); + + const chargeIndexNames = getTableConfig(charges).indexes.map((index) => index.config.name); + expect(chargeIndexNames).toContain('charges_property_folio_source_key_unique'); }); }); diff --git a/packages/database/src/migrations/0022_booking_request_accepted_pricing.sql b/packages/database/src/migrations/0022_booking_request_accepted_pricing.sql index 8864c932..6cb7c72a 100644 --- a/packages/database/src/migrations/0022_booking_request_accepted_pricing.sql +++ b/packages/database/src/migrations/0022_booking_request_accepted_pricing.sql @@ -3,3 +3,139 @@ -- rate-plan/night-audit behavior. ALTER TABLE reservations ADD COLUMN IF NOT EXISTS accepted_pricing_snapshot jsonb; + +-- This branch has never been released, so there is no truthful way to +-- reconstruct an accepted operational tariff from a legacy grand total. Abort +-- on an intermediate local database instead of inventing room/tax/service +-- allocations or silently permitting a reservation that night audit can +-- reprice. Operators can remove the unreleased local rows and rerun. +DO $booking_request_accepted_snapshot_precondition$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM booking_requests br + LEFT JOIN reservations r + ON r.id = br.accepted_reservation_id + AND r.property_id = br.property_id + WHERE br.status = 'accepted' + AND ( + br.accepted_reservation_id IS NULL + OR r.id IS NULL + OR r.accepted_pricing_snapshot IS NULL + OR ( + jsonb_typeof(r.accepted_pricing_snapshot) = 'object' + AND (r.accepted_pricing_snapshot ->> 'version') = '1' + AND (r.accepted_pricing_snapshot ->> 'source') IN ('submitted', 'current', 'custom') + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'currencyCode') = 'string' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'grandTotal') = 'string' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'roomTotal') = 'string' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'taxTotal') = 'string' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'servicesTotal') = 'string' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'servicesTaxTotal') = 'string' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'nights') = 'array' + AND jsonb_array_length(r.accepted_pricing_snapshot -> 'nights') > 0 + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'services') = 'array' + AND r.accepted_pricing_snapshot ? 'customReason' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'customReason') IN ('null', 'string') + AND ( + (r.accepted_pricing_snapshot ->> 'source') <> 'custom' + OR jsonb_typeof(r.accepted_pricing_snapshot -> 'customReason') = 'string' + ) + ) IS NOT TRUE + ) + ) THEN + RAISE EXCEPTION 'Cannot apply accepted pricing: an accepted Booking Request lacks a complete immutable reservation snapshot; no lossless backfill exists'; + END IF; +END +$booking_request_accepted_snapshot_precondition$; + +-- Pending requests may still be accepted using their submitted offer. Reject +-- the intermediate pre-review snapshot shape (which lacked posting lines) +-- rather than later interpreting a total in a newly selected currency or +-- pricing services from the live catalog. +DO $booking_request_submitted_quote_precondition$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM booking_requests + WHERE status = 'pending' + AND ( + jsonb_typeof(submitted_quote_snapshot) = 'object' + AND jsonb_typeof(submitted_quote_snapshot -> 'currencyCode') = 'string' + AND submitted_quote_snapshot ->> 'currencyCode' = currency_code + AND jsonb_typeof(submitted_quote_snapshot -> 'grandTotal') = 'string' + AND jsonb_typeof(submitted_quote_snapshot -> 'roomTotal') = 'string' + AND jsonb_typeof(submitted_quote_snapshot -> 'taxTotal') = 'string' + AND jsonb_typeof(submitted_quote_snapshot -> 'servicesTotal') = 'string' + AND jsonb_typeof(submitted_quote_snapshot -> 'servicesTaxTotal') = 'string' + AND jsonb_typeof(submitted_quote_snapshot -> 'lineItems') = 'array' + AND jsonb_array_length(submitted_quote_snapshot -> 'lineItems') > 0 + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + CASE + WHEN jsonb_typeof(submitted_quote_snapshot -> 'lineItems') = 'array' + THEN submitted_quote_snapshot -> 'lineItems' + ELSE '[]'::jsonb + END + ) AS night + WHERE ( + jsonb_typeof(night -> 'date') = 'string' + AND jsonb_typeof(night -> 'rate') = 'string' + AND jsonb_typeof(night -> 'tax') = 'string' + ) IS NOT TRUE + ) + AND jsonb_typeof(submitted_quote_snapshot -> 'services') = 'array' + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + CASE + WHEN jsonb_typeof(submitted_quote_snapshot -> 'services') = 'array' + THEN submitted_quote_snapshot -> 'services' + ELSE '[]'::jsonb + END + ) AS service + WHERE ( + jsonb_typeof(service -> 'serviceId') = 'string' + AND jsonb_typeof(service -> 'code') = 'string' + AND jsonb_typeof(service -> 'name') = 'string' + AND jsonb_typeof(service -> 'postingRule') = 'string' + AND jsonb_typeof(service -> 'chargeType') = 'string' + AND jsonb_typeof(service -> 'currencyCode') = 'string' + AND service ->> 'currencyCode' = currency_code + AND jsonb_typeof(service -> 'unitPrice') = 'string' + AND jsonb_typeof(service -> 'quantity') = 'number' + AND jsonb_typeof(service -> 'lineTotal') = 'string' + AND jsonb_typeof(service -> 'taxTotal') = 'string' + AND jsonb_typeof(service -> 'lineItems') = 'array' + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + CASE + WHEN jsonb_typeof(service -> 'lineItems') = 'array' + THEN service -> 'lineItems' + ELSE '[]'::jsonb + END + ) AS service_line + WHERE ( + jsonb_typeof(service_line -> 'date') = 'string' + AND jsonb_typeof(service_line -> 'amount') = 'string' + AND jsonb_typeof(service_line -> 'tax') = 'string' + ) IS NOT TRUE + ) + ) IS NOT TRUE + ) + ) IS NOT TRUE + ) THEN + RAISE EXCEPTION 'Cannot apply accepted pricing: a pending Booking Request has an incompatible submitted quote snapshot and no lossless backfill exists'; + END IF; +END +$booking_request_submitted_quote_precondition$; + +-- Only system-generated accepted-pricing rows receive a namespaced source +-- key. Existing/manual charges remain NULL, so no legacy value can collide. +ALTER TABLE charges + ADD COLUMN IF NOT EXISTS source_key varchar(255); + +CREATE UNIQUE INDEX IF NOT EXISTS charges_property_folio_source_key_unique + ON charges (property_id, folio_id, source_key); diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index ea1e1cd7..e0b68dbf 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -357,6 +357,7 @@ async function main() { is_reversal boolean NOT NULL DEFAULT false, original_charge_id uuid, parent_charge_id uuid REFERENCES charges(id), + source_key varchar(255), is_locked boolean NOT NULL DEFAULT false, locked_by_audit_date timestamp, posted_by uuid, @@ -1575,6 +1576,8 @@ async function main() { `ALTER TABLE charges ADD COLUMN IF NOT EXISTS house_account_id uuid`, // Split-component tax charges link to their parent charge (self-FK). `ALTER TABLE charges ADD COLUMN IF NOT EXISTS parent_charge_id uuid`, + `ALTER TABLE charges ADD COLUMN IF NOT EXISTS source_key varchar(255)`, + `CREATE UNIQUE INDEX IF NOT EXISTS charges_property_folio_source_key_unique ON charges (property_id, folio_id, source_key)`, `ALTER TABLE payments ALTER COLUMN folio_id DROP NOT NULL`, `ALTER TABLE payments ADD COLUMN IF NOT EXISTS house_account_id uuid`, `ALTER TABLE payments ADD COLUMN IF NOT EXISTS booking_request_id uuid REFERENCES booking_requests(id)`, @@ -1598,6 +1601,123 @@ async function main() { `ALTER TABLE booking_requests ALTER COLUMN submission_fingerprint SET NOT NULL`, `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_property_submission_key_unique ON booking_requests (property_id, submission_idempotency_key)`, `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_setup_intent_unique ON booking_requests (setup_intent_id)`, + `DO $booking_request_accepted_snapshot_precondition$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM booking_requests br + LEFT JOIN reservations r + ON r.id = br.accepted_reservation_id + AND r.property_id = br.property_id + WHERE br.status = 'accepted' + AND ( + br.accepted_reservation_id IS NULL + OR r.id IS NULL + OR r.accepted_pricing_snapshot IS NULL + OR ( + jsonb_typeof(r.accepted_pricing_snapshot) = 'object' + AND (r.accepted_pricing_snapshot ->> 'version') = '1' + AND (r.accepted_pricing_snapshot ->> 'source') IN ('submitted', 'current', 'custom') + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'currencyCode') = 'string' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'grandTotal') = 'string' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'roomTotal') = 'string' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'taxTotal') = 'string' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'servicesTotal') = 'string' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'servicesTaxTotal') = 'string' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'nights') = 'array' + AND jsonb_array_length(r.accepted_pricing_snapshot -> 'nights') > 0 + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'services') = 'array' + AND r.accepted_pricing_snapshot ? 'customReason' + AND jsonb_typeof(r.accepted_pricing_snapshot -> 'customReason') IN ('null', 'string') + AND ( + (r.accepted_pricing_snapshot ->> 'source') <> 'custom' + OR jsonb_typeof(r.accepted_pricing_snapshot -> 'customReason') = 'string' + ) + ) IS NOT TRUE + ) + ) THEN + RAISE EXCEPTION 'Cannot apply accepted pricing: an accepted Booking Request lacks a complete immutable reservation snapshot; no lossless backfill exists'; + END IF; + END + $booking_request_accepted_snapshot_precondition$`, + `DO $booking_request_submitted_quote_precondition$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM booking_requests + WHERE status = 'pending' + AND ( + jsonb_typeof(submitted_quote_snapshot) = 'object' + AND jsonb_typeof(submitted_quote_snapshot -> 'currencyCode') = 'string' + AND submitted_quote_snapshot ->> 'currencyCode' = currency_code + AND jsonb_typeof(submitted_quote_snapshot -> 'grandTotal') = 'string' + AND jsonb_typeof(submitted_quote_snapshot -> 'roomTotal') = 'string' + AND jsonb_typeof(submitted_quote_snapshot -> 'taxTotal') = 'string' + AND jsonb_typeof(submitted_quote_snapshot -> 'servicesTotal') = 'string' + AND jsonb_typeof(submitted_quote_snapshot -> 'servicesTaxTotal') = 'string' + AND jsonb_typeof(submitted_quote_snapshot -> 'lineItems') = 'array' + AND jsonb_array_length(submitted_quote_snapshot -> 'lineItems') > 0 + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + CASE + WHEN jsonb_typeof(submitted_quote_snapshot -> 'lineItems') = 'array' + THEN submitted_quote_snapshot -> 'lineItems' + ELSE '[]'::jsonb + END + ) AS night + WHERE ( + jsonb_typeof(night -> 'date') = 'string' + AND jsonb_typeof(night -> 'rate') = 'string' + AND jsonb_typeof(night -> 'tax') = 'string' + ) IS NOT TRUE + ) + AND jsonb_typeof(submitted_quote_snapshot -> 'services') = 'array' + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + CASE + WHEN jsonb_typeof(submitted_quote_snapshot -> 'services') = 'array' + THEN submitted_quote_snapshot -> 'services' + ELSE '[]'::jsonb + END + ) AS service + WHERE ( + jsonb_typeof(service -> 'serviceId') = 'string' + AND jsonb_typeof(service -> 'code') = 'string' + AND jsonb_typeof(service -> 'name') = 'string' + AND jsonb_typeof(service -> 'postingRule') = 'string' + AND jsonb_typeof(service -> 'chargeType') = 'string' + AND jsonb_typeof(service -> 'currencyCode') = 'string' + AND service ->> 'currencyCode' = currency_code + AND jsonb_typeof(service -> 'unitPrice') = 'string' + AND jsonb_typeof(service -> 'quantity') = 'number' + AND jsonb_typeof(service -> 'lineTotal') = 'string' + AND jsonb_typeof(service -> 'taxTotal') = 'string' + AND jsonb_typeof(service -> 'lineItems') = 'array' + AND NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + CASE + WHEN jsonb_typeof(service -> 'lineItems') = 'array' + THEN service -> 'lineItems' + ELSE '[]'::jsonb + END + ) AS service_line + WHERE ( + jsonb_typeof(service_line -> 'date') = 'string' + AND jsonb_typeof(service_line -> 'amount') = 'string' + AND jsonb_typeof(service_line -> 'tax') = 'string' + ) IS NOT TRUE + ) + ) IS NOT TRUE + ) + ) IS NOT TRUE + ) THEN + RAISE EXCEPTION 'Cannot apply accepted pricing: a pending Booking Request has an incompatible submitted quote snapshot and no lossless backfill exists'; + END IF; + END + $booking_request_submitted_quote_precondition$`, `ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS logical_event_id uuid`, `CREATE UNIQUE INDEX IF NOT EXISTS webhook_deliveries_property_subscription_logical_event_unique ON webhook_deliveries (property_id, subscription_id, logical_event_id)`, `DO $$ BEGIN diff --git a/packages/database/src/schema/folio.ts b/packages/database/src/schema/folio.ts index d499861c..ab4243c5 100644 --- a/packages/database/src/schema/folio.ts +++ b/packages/database/src/schema/folio.ts @@ -106,6 +106,9 @@ export const charges = pgTable('charges', { isReversal: boolean('is_reversal').notNull().default(false), originalChargeId: uuid('original_charge_id').references((): any => charges.id), // FK to self for reversals parentChargeId: uuid('parent_charge_id').references((): any => charges.id), // FK to self — tax charges linked to their parent charge + // Stable, namespaced identity for conflict-safe system posting. Legacy and + // manually entered rows remain NULL and cannot collide with these keys. + sourceKey: varchar('source_key', { length: 255 }), // Night audit lock (KB 5.8: transactions locked after day close) isLocked: boolean('is_locked').notNull().default(false), @@ -116,7 +119,10 @@ export const charges = pgTable('charges', { postedAt: timestamp('posted_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), -}); +}, (table) => ({ + propertyFolioSourceKeyUnique: uniqueIndex('charges_property_folio_source_key_unique') + .on(table.propertyId, table.folioId, table.sourceKey), +})); /** * Payment methods. diff --git a/packages/database/src/schema/reservation.ts b/packages/database/src/schema/reservation.ts index a0bb5079..8efbd8bc 100644 --- a/packages/database/src/schema/reservation.ts +++ b/packages/database/src/schema/reservation.ts @@ -74,6 +74,7 @@ export interface AcceptedPricingSnapshot { services: AcceptedPricingService[]; servicesTotal: string; servicesTaxTotal: string; + customReason: string | null; adjustment: null | { amount: string; reason: string; From d61d0b1cf65c61cce3375bed015028907ae3ac51 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 19:59:41 +0200 Subject: [PATCH 19/87] fix(booking-requests): suppress replayed service effects --- .../ancillary-accepted-pricing.spec.ts | 175 +++++++++++++++++- .../modules/ancillary/ancillary.service.ts | 42 +++-- .../src/modules/folio/folio.service.spec.ts | 28 ++- apps/api/src/modules/folio/folio.service.ts | 33 +++- 4 files changed, 255 insertions(+), 23 deletions(-) diff --git a/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts b/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts index d71219b1..cba5846f 100644 --- a/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts +++ b/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { AncillaryService } from './ancillary.service'; +import { WebhookService } from '../webhook/webhook.service'; function stagedSelect(stages: any[][]) { let index = 0; @@ -17,7 +18,167 @@ function stagedSelect(stages: any[][]) { }); } +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() { + const ledgerGroups: Array<{ base: { id: string }; tax: { id: string } }> = []; + 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), + }, + }; +} + describe('AncillaryService accepted operational pricing', () => { + it('lets only the concurrent once-service ledger winner transition and emit', async () => { + const reservation = { + id: 'res-1', + propertyId: 'prop-1', + guestId: 'guest-1', + arrivalDate: '2026-10-01', + acceptedPricingSnapshot: { + currencyCode: 'EUR', + services: [{ + serviceId: 'svc-1', + 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 update = vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn(async () => [{ ...rs, status: 'posted' }]), + })), + })), + })); + const createDb = (postedChargeRows: Array<{ id: string }>) => ({ + select: stagedSelect([ + [reservation], + [{ id: 'folio-1' }], + [{ rs, serviceName: 'Parking' }], + postedChargeRows, + ]), + update, + }); + const { ledgerGroups, folio } = idempotentSnapshotPoster(); + const { webhook, eventEmitter, audits } = recordedWebhookService(); + const first = new AncillaryService(createDb([]) as any, folio as any, webhook); + // Models the interleaving where this caller's preflight observes the + // winner's just-committed group after both selected a confirmed service. + const second = new AncillaryService( + createDb([{ id: 'charge-1' }]) 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(eventEmitter.emit).toHaveBeenCalledOnce(); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'reservation.service_posted', + expect.objectContaining({ entityId: 'rs-1', propertyId: 'prop-1' }), + ); + 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', + 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 = () => ({ + select: stagedSelect([ + [{ 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', @@ -61,7 +222,10 @@ describe('AncillaryService accepted operational pricing', () => { }; const folio = { postCharge: vi.fn(), - postChargeFromSnapshot: vi.fn().mockResolvedValue({ id: 'charge-1' }), + postChargeFromSnapshotWithOutcome: vi.fn().mockResolvedValue({ + charge: { id: 'charge-1' }, + wasCreated: true, + }), }; const service = new AncillaryService( db as any, @@ -71,7 +235,7 @@ describe('AncillaryService accepted operational pricing', () => { await service.postOnceForReservation('res-1', 'prop-1'); - expect(folio.postChargeFromSnapshot).toHaveBeenCalledWith( + expect(folio.postChargeFromSnapshotWithOutcome).toHaveBeenCalledWith( 'folio-1', expect.objectContaining({ amount: '15.00', currencyCode: 'EUR' }), '2.00', @@ -119,14 +283,17 @@ describe('AncillaryService accepted operational pricing', () => { }; const folio = { postCharge: vi.fn(), - postChargeFromSnapshot: vi.fn().mockResolvedValue({ id: 'charge-1' }), + postChargeFromSnapshotWithOutcome: vi.fn().mockResolvedValue({ + charge: { id: 'charge-1' }, + wasCreated: true, + }), }; 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.postChargeFromSnapshot).toHaveBeenCalledWith( + expect(folio.postChargeFromSnapshotWithOutcome).toHaveBeenCalledWith( 'folio-1', expect.objectContaining({ amount: '15.00', currencyCode: 'EUR' }), '2.00', diff --git a/apps/api/src/modules/ancillary/ancillary.service.ts b/apps/api/src/modules/ancillary/ancillary.service.ts index 6755d82e..f84f3497 100644 --- a/apps/api/src/modules/ancillary/ancillary.service.ts +++ b/apps/api/src/modules/ancillary/ancillary.service.ts @@ -491,7 +491,16 @@ export class AncillaryService { reservation.arrivalDate ?? new Date().toISOString().slice(0, 10); for (const { rs, serviceName } of rows) { - if (await this.hasPostedCharge(folio.id, propertyId, rs.id)) { + const acceptedLine = this.acceptedServiceLine( + reservation, + rs.serviceId, + serviceDate, + true, + ); + // Accepted pricing uses the database source-key claim as its authority. + // A preflight read can race with the winner and must not grant the loser + // permission to transition the service row. + if (!acceptedLine && await this.hasPostedCharge(folio.id, propertyId, rs.id)) { if (rs.status === 'confirmed') { await this.db .update(reservationServices) @@ -506,18 +515,13 @@ export class AncillaryService { continue; } - const acceptedLine = this.acceptedServiceLine( - reservation, - rs.serviceId, - serviceDate, - true, - ); const amount = acceptedLine?.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. + let wasCreated = true; if (new Decimal(amount).greaterThan(0)) { const chargeInput = { propertyId, @@ -529,18 +533,23 @@ export class AncillaryService { guestId: reservation.guestId, }; if (acceptedLine) { - await this.folioService.postChargeFromSnapshot( + const outcome = await this.folioService.postChargeFromSnapshotWithOutcome( folio.id, chargeInput, acceptedLine.taxAmount, undefined, `accepted-pricing:reservation-service:${rs.id}:once`, ); + wasCreated = outcome.wasCreated; } else { await this.folioService.postCharge(folio.id, chargeInput); } } + // A concurrent source-key loser observes the winner's immutable ledger + // group as success, but must not repeat the domain transition or event. + if (!wasCreated) continue; + const [updated] = await this.db .update(reservationServices) .set({ status: 'posted', updatedAt: new Date() }) @@ -548,10 +557,13 @@ export class AncillaryService { and( eq(reservationServices.id, rs.id), eq(reservationServices.propertyId, propertyId), + eq(reservationServices.status, 'confirmed' as any), ), ) .returning(); + if (!updated) continue; + await this.webhookService.emit( 'reservation.service_posted', 'reservation_service', @@ -658,15 +670,23 @@ export class AncillaryService { serviceDate: new Date(date + 'T00:00:00Z').toISOString(), guestId: reservation.guestId, }; - const charge = acceptedLine - ? await this.folioService.postChargeFromSnapshot( + const outcome = acceptedLine + ? await this.folioService.postChargeFromSnapshotWithOutcome( folio.id, chargeInput, acceptedLine.taxAmount, undefined, `accepted-pricing:reservation-service:${rs.id}:night:${date}`, ) - : await this.folioService.postCharge(folio.id, chargeInput); + : { + 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( diff --git a/apps/api/src/modules/folio/folio.service.spec.ts b/apps/api/src/modules/folio/folio.service.spec.ts index 51937e5a..05ff9107 100644 --- a/apps/api/src/modules/folio/folio.service.spec.ts +++ b/apps/api/src/modules/folio/folio.service.spec.ts @@ -517,19 +517,26 @@ describe('FolioService', () => { }; const sourceKey = 'accepted-pricing:reservation-service:rs-1:once'; - const results = await Promise.all([ - (svc.postChargeFromSnapshot as any)( + const outcomes = await Promise.all([ + (svc as any).postChargeFromSnapshotWithOutcome( 'folio-001', input, '2.00', undefined, sourceKey, ), - (svc.postChargeFromSnapshot as any)( + (svc as any).postChargeFromSnapshotWithOutcome( 'folio-001', input, '2.00', undefined, sourceKey, ), ]); expect(ledger.map((row) => row.type)).toEqual(['parking', 'tax']); - expect(results[0].id).toBe(results[1].id); - expect(results[0].taxCharges).toEqual(results[1].taxCharges); + 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'); }); }); @@ -647,6 +654,7 @@ describe('FolioService', () => { parentChargeId: 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)), @@ -656,6 +664,12 @@ describe('FolioService', () => { 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') @@ -713,6 +727,10 @@ describe('FolioService', () => { svc.reverseCharge('folio-001', base.id, 'prop-001'), ).rejects.toThrow(/already been reversed/i); expect(inserted).toHaveLength(3); + expect(chargeLookupPredicates.length).toBeGreaterThan(0); + expect(chargeLookupPredicates.every((predicate) => + predicate.columns.includes('property_id') + && predicate.params.includes('prop-001'))).toBe(true); }); }); diff --git a/apps/api/src/modules/folio/folio.service.ts b/apps/api/src/modules/folio/folio.service.ts index fb9a3c4b..deb96772 100644 --- a/apps/api/src/modules/folio/folio.service.ts +++ b/apps/api/src/modules/folio/folio.service.ts @@ -440,6 +440,27 @@ export class FolioService { taxAmount: string, adjustment?: { amount: string; reason: string }, sourceKey?: string, + ) { + const outcome = await this.postChargeFromSnapshotWithOutcome( + folioId, + dto, + taxAmount, + adjustment, + sourceKey, + ); + return outcome.charge; + } + + /** + * 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, ) { const result = await this.db.transaction(async (tx: any) => { const base = await this.postCharge(folioId, { @@ -501,7 +522,7 @@ export class FolioService { if (!result.wasCreated) { const { wasCreated: _wasCreated, ...existing } = result; void _wasCreated; - return existing; + return { charge: existing, wasCreated: false as const }; } await this.webhookService.emit( @@ -546,7 +567,7 @@ export class FolioService { } const { wasCreated: _wasCreated, ...posted } = result; void _wasCreated; - return posted; + return { charge: posted, wasCreated: true as const }; } async reverseCharge(folioId: string, chargeId: string, propertyId: string) { @@ -584,6 +605,7 @@ export class FolioService { and( eq(charges.originalChargeId, chargeId), eq(charges.isReversal, true), + eq(charges.propertyId, propertyId), ), ); if (existing) { @@ -619,6 +641,7 @@ export class FolioService { and( eq(charges.parentChargeId, chargeId), eq(charges.isReversal, false), + eq(charges.propertyId, propertyId), ), ); const childCharges = typeof childQuery.for === 'function' @@ -630,7 +653,11 @@ export class FolioService { .select() .from(charges) .where( - and(eq(charges.originalChargeId, childCharge.id), eq(charges.isReversal, true)), + and( + eq(charges.originalChargeId, childCharge.id), + eq(charges.isReversal, true), + eq(charges.propertyId, propertyId), + ), ); if (existingChildReversal) continue; From f19dc129bcbbd60d5f54ff0c8299da915524c587 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 20:06:29 +0200 Subject: [PATCH 20/87] fix(booking-requests): recover once-service posting state --- .../ancillary-accepted-pricing.spec.ts | 136 ++++++++++-------- .../modules/ancillary/ancillary.service.ts | 11 +- 2 files changed, 84 insertions(+), 63 deletions(-) diff --git a/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts b/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts index cba5846f..dced5c56 100644 --- a/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts +++ b/apps/api/src/modules/ancillary/ancillary-accepted-pricing.spec.ts @@ -31,8 +31,14 @@ function recordedWebhookService() { return { webhook, eventEmitter, audits }; } -function idempotentSnapshotPoster() { +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) { @@ -54,64 +60,62 @@ function idempotentSnapshotPoster() { }; } -describe('AncillaryService accepted operational pricing', () => { - it('lets only the concurrent once-service ledger winner transition and emit', async () => { - const reservation = { - id: 'res-1', - propertyId: 'prop-1', - guestId: 'guest-1', - arrivalDate: '2026-10-01', - acceptedPricingSnapshot: { - currencyCode: 'EUR', - services: [{ - serviceId: 'svc-1', - 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', +function acceptedOnceScenario() { + const reservation = { + id: 'res-1', + propertyId: 'prop-1', + guestId: 'guest-1', + arrivalDate: '2026-10-01', + acceptedPricingSnapshot: { currencyCode: 'EUR', - postingRule: 'once', - status: 'confirmed', - }; - const update = vi.fn(() => ({ - set: vi.fn(() => ({ - where: vi.fn(() => ({ - returning: vi.fn(async () => [{ ...rs, status: 'posted' }]), - })), - })), - })); - const createDb = (postedChargeRows: Array<{ id: string }>) => ({ - select: stagedSelect([ - [reservation], - [{ id: 'folio-1' }], - [{ rs, serviceName: 'Parking' }], - postedChargeRows, - ]), - update, - }); - const { ledgerGroups, folio } = idempotentSnapshotPoster(); + services: [{ + serviceId: 'svc-1', + 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 })), + })), + })); + const createDb = () => ({ + select: stagedSelect([ + [reservation], + [{ id: 'folio-1' }], + [{ rs, serviceName: 'Parking' }], + ]), + update, + }); + return { createDb, update, casReturning }; +} + +describe('AncillaryService accepted operational pricing', () => { + 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 first = new AncillaryService(createDb([]) as any, folio as any, webhook); - // Models the interleaving where this caller's preflight observes the - // winner's just-committed group after both selected a confirmed service. - const second = new AncillaryService( - createDb([{ id: 'charge-1' }]) as any, - folio as any, - webhook, - ); + const service = 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'), - ]); + const result = await service.postOnceForReservation('res-1', 'prop-1'); expect(ledgerGroups).toHaveLength(1); expect(update).toHaveBeenCalledOnce(); @@ -121,6 +125,26 @@ describe('AncillaryService accepted operational pricing', () => { 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).toHaveBeenCalledTimes(2); + expect(casReturning).toHaveBeenCalledTimes(2); + expect(eventEmitter.emit).toHaveBeenCalledOnce(); + expect(audits).toHaveLength(1); expect(results.map((result) => result.count).sort()).toEqual([0, 1]); }); diff --git a/apps/api/src/modules/ancillary/ancillary.service.ts b/apps/api/src/modules/ancillary/ancillary.service.ts index f84f3497..d813d6eb 100644 --- a/apps/api/src/modules/ancillary/ancillary.service.ts +++ b/apps/api/src/modules/ancillary/ancillary.service.ts @@ -521,7 +521,6 @@ export class AncillaryService { // FolioService rejects non-positive amounts except adjustments/reversals. // Zero-priced included lines are marked posted without a ledger row. - let wasCreated = true; if (new Decimal(amount).greaterThan(0)) { const chargeInput = { propertyId, @@ -533,23 +532,21 @@ export class AncillaryService { guestId: reservation.guestId, }; if (acceptedLine) { - const outcome = await this.folioService.postChargeFromSnapshotWithOutcome( + await this.folioService.postChargeFromSnapshotWithOutcome( folio.id, chargeInput, acceptedLine.taxAmount, undefined, `accepted-pricing:reservation-service:${rs.id}:once`, ); - wasCreated = outcome.wasCreated; } else { await this.folioService.postCharge(folio.id, chargeInput); } } - // A concurrent source-key loser observes the winner's immutable ledger - // group as success, but must not repeat the domain transition or event. - if (!wasCreated) continue; - + // Ledger creation and service-state recovery are separate idempotency + // boundaries. Creators and replays both attempt this CAS so a replay can + // recover a crash after the ledger commit; only the CAS winner emits. const [updated] = await this.db .update(reservationServices) .set({ status: 'posted', updatedAt: new Date() }) From 50b3467e4a0ef3a08285a3f9ec47617047070176 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 20:40:48 +0200 Subject: [PATCH 21/87] feat(booking-requests): manage partial payments --- .../booking-request-payment.service.ts | 1307 +++++++++++++++++ .../booking-request-payment.spec.ts | 935 ++++++++++++ .../booking-request.controller.ts | 166 +++ .../booking-request/booking-request.module.ts | 4 +- .../dto/booking-request-payment.dto.ts | 172 +++ .../modules/payment/payment-ledger.spec.ts | 9 +- .../api/src/modules/payment/payment-ledger.ts | 26 + .../modules/payment/payment.service.spec.ts | 112 ++ .../src/modules/payment/payment.service.ts | 95 +- .../payment/stripe-webhook.controller.ts | 85 +- .../modules/payment/stripe-webhook.spec.ts | 128 +- 11 files changed, 3016 insertions(+), 23 deletions(-) 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/dto/booking-request-payment.dto.ts 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..4bd92420 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-payment.service.ts @@ -0,0 +1,1307 @@ +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + auditLogs, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + bookingRequestPaymentResolutions, + bookingRequests, + payments, +} from '@telivityhaip/database'; +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 { + SAVED_PAYMENT_METHOD_GATEWAY, + type SavedPaymentMethodGateway, +} from '../payment/interfaces/saved-payment-method-gateway.interface'; +import { PaymentService } from '../payment/payment.service'; +import { assertAllocationAmount, resolveInstallmentAmount } from './booking-request-money'; +import type { + AllocateBookingRequestPaymentDto, + ChargeBookingRequestCardDto, + CreateBookingRequestInstallmentDto, + RecordBookingRequestExternalPaymentDto, + RecordBookingRequestExternalReturnDto, + 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 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(PaymentService) private readonly paymentService: PaymentService, + @Inject(FolioService) private readonly folioService: FolioService, + ) {} + + 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), + )), + ]); + return { + movements: movementRows + .filter((row: PaymentRow) => + row.propertyId === propertyId && row.bookingRequestId === bookingRequestId) + .map((row: PaymentRow) => this.paymentResponse(row)), + allocations: allocationRows.filter((row: AllocationRow) => + row.propertyId === propertyId && row.bookingRequestId === bookingRequestId), + resolutions: resolutionRows.filter((row: ResolutionRow) => + row.propertyId === propertyId && row.bookingRequestId === bookingRequestId), + }; + } + + 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); + 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, + 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); + const existing = await this.findInstallment( + tx, + bookingRequestId, + installmentId, + propertyId, + true, + ); + const persistedAllocation = await this.installmentAllocationTotal( + tx, + bookingRequestId, + installmentId, + propertyId, + ); + if (new Decimal(existing.allocatedAmount).gt(0) || persistedAllocation.gt(0)) { + throw new ConflictException('An allocated installment cannot be edited'); + } + + 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); + const updatedAt = new Date(); + const candidates = await tx + .update(bookingRequestInstallments) + .set({ ...normalized, 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, + 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<{ deleted: true; installmentId: string }> { + 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 (new Decimal(existing.allocatedAmount).gt(0) || persistedAllocation.gt(0)) { + throw new ConflictException('An allocated installment cannot be deleted'); + } + await tx + .delete(bookingRequestInstallments) + .where(and( + eq(bookingRequestInstallments.id, installmentId), + eq(bookingRequestInstallments.bookingRequestId, bookingRequestId), + eq(bookingRequestInstallments.propertyId, propertyId), + )); + await this.audit(tx, { + propertyId, + action: 'delete', + entityType: 'booking_request_installment', + entityId: installmentId, + actor, + previousValue: this.installmentAuditValue(existing), + description: 'Booking request installment deleted', + }); + return { deleted: true, installmentId }; + }); + } + + 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); + this.assertNotDenied(request); + const installment = await this.findInstallment( + tx, + bookingRequestId, + installmentId, + propertyId, + true, + ); + const payment = await this.findParentPayment( + tx, + bookingRequestId, + input.paymentId, + 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 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: payment.amount, + 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, + 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) { + const existing = await this.findPaymentByIdempotency(tx, propertyId, idempotencyKey); + this.assertPaymentReplay(existing, { + bookingRequestId, + amount: amount.toFixed(2), + currencyCode: request.currencyCode, + method: 'credit_card', + }, 'charge idempotency key'); + return { payment: existing, request, isNew: false }; + } + await this.audit(tx, { + propertyId, + 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) return this.paymentResponse(prepared.payment); + + let gatewayResult: Awaited>; + try { + gatewayResult = await this.savedPaymentMethodGateway.charge({ + customerId: prepared.request.stripeCustomerId!, + paymentMethodId: prepared.request.stripePaymentMethodId!, + amount: prepared.payment.amount, + currencyCode: prepared.payment.currencyCode, + idempotencyKey, + }); + } catch (error: unknown) { + gatewayResult = { + success: false, + transactionId: '', + requiresAction: false, + errorMessage: error instanceof Error ? error.message : 'Saved-card charge failed', + }; + } + + const finalized = await this.db.transaction(async (tx: any) => { + 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, + 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, + 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', + }); + return updated; + }); + + if (finalized.status === 'captured' && finalized.folioId) { + await this.folioService.recalculateBalance(finalized.folioId, 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, + `${provider}:${reference}`, + ); + const result = await this.db.transaction(async (tx: any) => { + const request = await this.findRequest(tx, bookingRequestId, propertyId, true); + this.assertNotDenied(request); + 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, + }, 'external payment reference'); + return { payment: existing, isNew: false }; + } + await this.audit(tx, { + propertyId, + 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', + }); + return { payment: created, isNew: true }; + }); + if (result.isNew && result.payment.folioId) { + await this.folioService.recalculateBalance(result.payment.folioId, propertyId); + } + return this.paymentResponse(result.payment); + } + + async refund( + bookingRequestId: string, + paymentId: string, + propertyId: string, + input: RefundBookingRequestPaymentDto, + actor?: AuditActor, + ) { + const request = await this.findRequest(this.db, bookingRequestId, propertyId); + this.assertNotDenied(request); + const original = await this.findParentPayment( + this.db, + bookingRequestId, + paymentId, + propertyId, + ); + 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'); + await this.assertResolutionCapacity( + this.db, + bookingRequestId, + propertyId, + original, + amount, + ); + const idempotencyKey = this.scopedKey('refund', propertyId, input.idempotencyKey); + const movement = await this.paymentService.refundPayment( + paymentId, + propertyId, + amount.toFixed(2), + { idempotencyKey }, + ); + const resolution = await this.recordResolution({ + bookingRequestId, + propertyId, + paymentId, + type: 'refund', + amount: new Decimal(movement.amount).abs().toFixed(2), + reason: `Gateway refund movement ${movement.id}`, + actor, + marker: movement.id, + }); + return { movement: this.paymentResponse(movement), 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) { + this.assertPaymentReplay(existing, { + bookingRequestId, + amount: amount.negated().toFixed(2), + currencyCode: original.currencyCode, + method: original.method, + reference, + originalPaymentId: original.id, + }, '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, + }); + return { movement: existing, resolution, isNew: false }; + } + await this.assertResolutionCapacity( + tx, + bookingRequestId, + propertyId, + original, + amount, + ); + const [movement] = await tx + .insert(payments) + .values({ + propertyId, + bookingRequestId, + folioId: original.folioId, + 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, + }); + await this.audit(tx, { + propertyId, + 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', + }); + return { movement, resolution, isNew: true }; + }); + if (result.isNew && result.movement.folioId) { + await this.folioService.recalculateBalance(result.movement.folioId, propertyId); + } + return { + movement: this.paymentResponse(result.movement), + resolution: 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'); + return 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, + ); + 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, + ); + return this.ensureResolution(tx, { + bookingRequestId, + propertyId, + paymentId, + type: 'retained', + amount: amount.toFixed(2), + reason, + actor, + }); + }); + } + + 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, + }); + 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.gte(1000)) { + throw new BadRequestException('Installment percentage exceeds storage precision'); + } + 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 = this.currencyExponent(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 currencyExponent(currencyCode: string): number { + try { + const exponent = new Intl.NumberFormat('en', { + style: 'currency', + currency: currencyCode.trim().toUpperCase(), + }).resolvedOptions().maximumFractionDigits; + if (exponent == null) throw new Error('missing exponent'); + return exponent; + } catch { + throw new BadRequestException(`Unsupported currency '${currencyCode}'`); + } + } + + 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; + }, + 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.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 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 resolutions = await this.scopedResolutions(db, bookingRequestId, propertyId); + const resolved = resolutions + .filter((row) => row.paymentId === payment.id) + .reduce((sum, row) => sum.plus(row.amount), new Decimal(0)); + const remaining = new Decimal(payment.amount).minus(resolved); + if (amount.gt(remaining)) { + throw new ConflictException( + `Resolution amount ${amount.toFixed(2)} exceeds remaining captured amount ${remaining.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; + }, + ) { + 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) return existing; + } + const [resolution] = await tx + .insert(bookingRequestPaymentResolutions) + .values({ + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + paymentId: input.paymentId, + type: input.type, + amount: input.amount, + reason: input.reason ?? null, + resolvedBy: input.actor?.userId ?? null, + resolvedAt: new Date(), + }) + .returning(); + await this.audit(tx, { + propertyId: input.propertyId, + 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) { + 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, + gatewayProvider: row.gatewayProvider, + gatewayTransactionId: row.gatewayTransactionId, + cardLastFour: row.cardLastFour, + cardBrand: row.cardBrand, + originalPaymentId: row.originalPaymentId, + notes: row.notes, + processedAt: row.processedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + private async audit( + db: any, + input: { + propertyId: 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, + 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..99e198b3 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-payment.spec.ts @@ -0,0 +1,935 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { + auditLogs, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + bookingRequestPaymentResolutions, + bookingRequests, + payments, +} from '@telivityhaip/database'; +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, + 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>; +}; + +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', + ...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; + throw new Error('Unexpected table in payment test'); +} + +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'); + } + } + 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(false)).then(resolve, reject), + }; + return result; + }), + })); + + const update = vi.fn((table: unknown) => ({ + set: vi.fn((changes: Record) => ({ + where: vi.fn(() => { + const apply = () => { + const rows = tableRows(state, table); + for (const row of rows) Object.assign(row, structuredClone(changes)); + return structuredClone(rows); + }; + 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: [], + ...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 canonicalPaymentService = { + refundPayment: vi.fn(), + }; + const folioService = { + recalculateBalance: vi.fn(), + }; + const service = new (BookingRequestPaymentService as any)( + database.db, + gateway, + canonicalPaymentService, + folioService, + ) as BookingRequestPaymentService; + return { + service, + state, + database, + gateway, + canonicalPaymentService, + folioService, + 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', + '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); + + 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); + }); + + 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('blocks editing and deletion after any amount has been allocated', async () => { + const allocated = installment({ allocatedAmount: '1.00', status: 'partial' }); + const editHarness = makeHarness({ installments: [allocated] }); + await expect(editHarness.service.updateInstallment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + { label: 'Changed' }, + actor, + )).rejects.toThrow(/allocated/i); + + const deleteHarness = makeHarness({ installments: [allocated] }); + await expect(deleteHarness.service.deleteInstallment( + REQUEST_ID, + INSTALLMENT_ID, + PROPERTY_ID, + actor, + )).rejects.toThrow(/allocated/i); + }); + + 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, + )).rejects.toThrow(/allocated/i); + }); + + 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', + 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', + gatewayTransactionId: 'pi_saved_1', + }); + expect(result).not.toHaveProperty('gatewayPaymentToken'); + expect(result).not.toHaveProperty('idempotencyKey'); + }); + + 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('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.gatewayTransactionId).toBe(gatewayResult.transactionId); + expect(JSON.stringify(result)).not.toMatch(/client_secret|authentication_url|https?:\/\//i); + } + }); + + 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, + ); + }); + + 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); + } + }); +}); + +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', + amount: '10.00', + reason: 'Supplier fee', + }], + }); + + 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.allocations).toHaveLength(1); + expect(result.resolutions).toHaveLength(1); + }); + + 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', + gatewayTransactionId: 'wire-abc', + }); + expect(second.id).toBe(first.id); + expect(harness.state.payments).toHaveLength(1); + + await expect(harness.service.recordExternalPayment( + REQUEST_ID, + PROPERTY_ID, + { ...input, amount: '75.11' }, + actor, + )).rejects.toThrow(/reference/i); + }); + + 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, + ); + }); + + 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 refund = { + ...capturedPayment(), + id: 'dddddddd-0000-4000-a000-000000000002', + amount: '-35.00', + originalPaymentId: PAYMENT_ID, + idempotencyKey: 'booking-request-refund:partial-1', + }; + const harness = makeHarness({ payments: [original] }); + harness.canonicalPaymentService.refundPayment.mockResolvedValueOnce(refund); + + const result = await harness.service.refund( + REQUEST_ID, + PAYMENT_ID, + PROPERTY_ID, + { amount: '35.00', idempotencyKey: 'partial-refund-1' }, + actor, + ); + expect(harness.canonicalPaymentService.refundPayment).toHaveBeenCalledWith( + PAYMENT_ID, + PROPERTY_ID, + '35.00', + { idempotencyKey: expect.stringContaining('booking-request-refund:') }, + ); + expect(result.movement).toMatchObject({ + id: refund.id, + originalPaymentId: PAYMENT_ID, + amount: '-35.00', + }); + expect(result.resolution).toMatchObject({ + paymentId: PAYMENT_ID, + type: 'refund', + amount: '35.00', + }); + }); + + 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.canonicalPaymentService.refundPayment).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', + gatewayTransactionId: 'return-1', + }); + expect(result.resolution).toMatchObject({ + paymentId: PAYMENT_ID, + type: 'external_return', + amount: '30.00', + }); + }); + + 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, + }); + }); + + 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, + }), + ])); + }); +}); diff --git a/apps/api/src/modules/booking-request/booking-request.controller.ts b/apps/api/src/modules/booking-request/booking-request.controller.ts index 09ab4d21..b46d0094 100644 --- a/apps/api/src/modules/booking-request/booking-request.controller.ts +++ b/apps/api/src/modules/booking-request/booking-request.controller.ts @@ -1,10 +1,12 @@ import { Body, Controller, + Delete, Get, Inject, Param, ParseUUIDPipe, + Patch, Post, Query, } from '@nestjs/common'; @@ -14,6 +16,7 @@ import { type AuditActor, } from '../../common/audit/audit-actor'; import { RequirePermissions } from '../auth/permissions.decorator'; +import { BookingRequestPaymentService } from './booking-request-payment.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 @@ -22,12 +25,25 @@ import { AcceptBookingRequestDto } from './dto/accept-booking-request.dto'; 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 { + AllocateBookingRequestPaymentDto, + ChargeBookingRequestCardDto, + CreateBookingRequestInstallmentDto, + RecordBookingRequestExternalPaymentDto, + RecordBookingRequestExternalReturnDto, + 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, ) {} @Get() @@ -73,4 +89,154 @@ export class BookingRequestController { ) { 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/:installmentId') + @RequirePermissions('reservations.write') + @ApiQuery({ name: 'propertyId', required: true }) + @ApiOperation({ summary: 'Edit an unallocated payment-plan installment' }) + 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 payment-plan installment' }) + 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.module.ts b/apps/api/src/modules/booking-request/booking-request.module.ts index d99fa0e1..589f4146 100644 --- a/apps/api/src/modules/booking-request/booking-request.module.ts +++ b/apps/api/src/modules/booking-request/booking-request.module.ts @@ -14,6 +14,7 @@ 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'; @Module({ imports: [ @@ -29,11 +30,12 @@ import { BookingRequestConsequenceWorkerService } from './booking-request-conseq controllers: [BookingRequestPublicController, BookingRequestController], providers: [ BookingRequestService, + BookingRequestPaymentService, BookingRequestConsequenceWorkerService, BookingKeyGuard, BookingEngineScopeGuard, BookingThrottleGuard, ], - exports: [BookingRequestService], + exports: [BookingRequestService, BookingRequestPaymentService], }) export class BookingRequestModule {} 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..120359ab --- /dev/null +++ b/apps/api/src/modules/booking-request/dto/booking-request-payment.dto.ts @@ -0,0 +1,172 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { + 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 999.99' }) + @IsOptional() + @IsMoneyString() + 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 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/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.service.spec.ts b/apps/api/src/modules/payment/payment.service.spec.ts index e5b6e232..7e6460f4 100644 --- a/apps/api/src/modules/payment/payment.service.spec.ts +++ b/apps/api/src/modules/payment/payment.service.spec.ts @@ -529,6 +529,118 @@ describe('PaymentService', () => { expect(result).toEqual(refundPayment); }); + it('preserves Booking Request provenance and does not recalculate a missing folio', async () => { + const requestPayment = { + ...mockPayment, + folioId: null, + bookingRequestId: 'request-001', + status: 'captured', + }; + const refundPayment = { + ...mockPayment, + id: 'pay-request-refund', + folioId: null, + bookingRequestId: 'request-001', + amount: '-25.00', + originalPaymentId: 'pay-001', + }; + let insertedValues: Record | undefined; + 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: vi.fn(() => ({ + values: vi.fn((values: Record) => { + insertedValues = values; + return { returning: vi.fn().mockResolvedValue([refundPayment]) }; + }), + })), + }; + }; + 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 module.get(PaymentService).refundPayment( + 'pay-001', + 'prop-001', + '25.00', + ); + + expect(insertedValues).toEqual(expect.objectContaining({ + bookingRequestId: 'request-001', + folioId: null, + originalPaymentId: 'pay-001', + amount: '-25.00', + })); + 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).toBe(existingRefund); + expect(mockGateway.refund).not.toHaveBeenCalled(); + }); + // Partial refunds: parent stays captured; negative children net the folio balance. describe('multi-refund partial scenario', () => { function buildRefundTxDb( diff --git a/apps/api/src/modules/payment/payment.service.ts b/apps/api/src/modules/payment/payment.service.ts index 7579344c..5cef3cb6 100644 --- a/apps/api/src/modules/payment/payment.service.ts +++ b/apps/api/src/modules/payment/payment.service.ts @@ -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( @@ -314,7 +319,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() @@ -338,7 +348,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,12 +405,24 @@ 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 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(), @@ -400,6 +455,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 +514,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 +532,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', diff --git a/apps/api/src/modules/payment/stripe-webhook.controller.ts b/apps/api/src/modules/payment/stripe-webhook.controller.ts index 9aba09a3..a173c8c6 100644 --- a/apps/api/src/modules/payment/stripe-webhook.controller.ts +++ b/apps/api/src/modules/payment/stripe-webhook.controller.ts @@ -8,11 +8,15 @@ import { Inject, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { ApiTags, ApiOperation, ApiExcludeEndpoint } from '@nestjs/swagger'; +import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; import { Public } from '../auth/public.decorator'; import { eq, and } from 'drizzle-orm'; import { Decimal } from 'decimal.js'; -import { payments } from '@telivityhaip/database'; +import { + auditLogs, + bookingRequestPaymentResolutions, + payments, +} from '@telivityhaip/database'; import { DRIZZLE } from '../../database/database.module'; import { WebhookService } from '../webhook/webhook.service'; import { FolioService } from '../folio/folio.service'; @@ -142,8 +146,10 @@ export class StripeWebhookController { .set({ status: 'captured', processedAt: new Date(), updatedAt: new Date() }) .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); + // Pre-acceptance Booking Request movements do not have a folio yet. + if (payment.folioId) { + await this.folioService.recalculateBalance(payment.folioId, payment.propertyId); + } await this.webhookService.emit( 'payment.received', @@ -169,8 +175,9 @@ export class StripeWebhookController { .set({ status: 'failed', notes: errorMessage, updatedAt: new Date() }) .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', @@ -194,8 +201,9 @@ export class StripeWebhookController { .set({ status: 'voided', updatedAt: new Date() }) .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', @@ -218,7 +226,10 @@ export class StripeWebhookController { const payment = await this.findPaymentByGatewayTransactionId(piId); if (!payment) return; - const stripeRefundedDec = new Decimal(charge.amount_refunded).div(100); + const stripeRefundedDec = this.fromStripeMinorUnits( + charge.amount_refunded, + charge.currency ?? payment.currencyCode, + ); const ledgerKey = `stripe_refund:${charge.id}:${stripeRefundedDec.toFixed(2)}`; const recorded = await this.db.transaction(async (tx: any) => { @@ -269,6 +280,7 @@ export class StripeWebhookController { .values({ folioId: parent.folioId, propertyId: parent.propertyId, + bookingRequestId: parent.bookingRequestId, method: parent.method, amount: deltaDec.negated().toFixed(2), currencyCode: parent.currencyCode, @@ -281,7 +293,37 @@ export class StripeWebhookController { }) .returning(); - await this.folioService.recalculateBalance(parent.folioId, parent.propertyId, tx); + if (parent.bookingRequestId) { + const [resolution] = await tx + .insert(bookingRequestPaymentResolutions) + .values({ + propertyId: parent.propertyId, + bookingRequestId: parent.bookingRequestId, + paymentId: parent.id, + type: 'refund', + amount: deltaDec.toFixed(2), + reason: `Gateway refund movement ${row.id}`, + resolvedAt: new Date(), + }) + .returning(); + await tx.insert(auditLogs).values({ + propertyId: parent.propertyId, + action: 'create', + entityType: 'booking_request_payment_resolution', + entityId: resolution.id, + newValue: { + requestId: parent.bookingRequestId, + paymentId: parent.id, + type: 'refund', + amount: deltaDec.toFixed(2), + }, + description: 'Stripe refund resolved Booking Request money', + }); + } + + if (parent.folioId) { + await this.folioService.recalculateBalance(parent.folioId, parent.propertyId, tx); + } return { row, parent, deltaDec }; }); @@ -305,6 +347,29 @@ export class StripeWebhookController { ); } + 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}'`); + } + 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 [payment] = await this.db .select() diff --git a/apps/api/src/modules/payment/stripe-webhook.spec.ts b/apps/api/src/modules/payment/stripe-webhook.spec.ts index 9e3d92de..8089e57d 100644 --- a/apps/api/src/modules/payment/stripe-webhook.spec.ts +++ b/apps/api/src/modules/payment/stripe-webhook.spec.ts @@ -4,6 +4,10 @@ import { StripeWebhookController } from './stripe-webhook.controller'; import { WebhookService } from '../webhook/webhook.service'; import { FolioService } from '../folio/folio.service'; import { DRIZZLE } from '../../database/database.module'; +import { + bookingRequestPaymentResolutions, + payments, +} from '@telivityhaip/database'; const mockPayment = { id: 'pay-001', @@ -11,6 +15,7 @@ const mockPayment = { folioId: 'folio-001', status: 'authorized', amount: '500.00', + currencyCode: 'USD', gatewayTransactionId: 'pi_test_123', }; @@ -19,6 +24,8 @@ function createRefundWebhookDb( existingRefunds: any[] = [], existingForLedger: any[] = [], ) { + let insertedValues: Record | undefined; + let resolutionValues: Record | undefined; return { select: vi.fn().mockImplementation(() => ({ from: vi.fn().mockReturnValue({ @@ -48,17 +55,30 @@ function createRefundWebhookDb( }), }), })), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([ - { id: 'refund-webhook-1', folioId: payment.folioId, originalPaymentId: payment.id }, - ]), + insert: vi.fn((table: unknown) => ({ + values: vi.fn((values: Record) => { + if (table === payments) insertedValues = values; + if (table === bookingRequestPaymentResolutions) resolutionValues = values; + return { + returning: vi.fn().mockResolvedValue([ + table === payments + ? { + id: 'refund-webhook-1', + folioId: payment.folioId, + bookingRequestId: payment.bookingRequestId, + originalPaymentId: payment.id, + } + : { id: 'resolution-webhook-1', ...values }, + ]), + }; }), - }), + })), }; return fn(tx); }), update: vi.fn(), + getInsertedValues: () => insertedValues, + getResolutionValues: () => resolutionValues, }; } @@ -147,6 +167,29 @@ describe('StripeWebhookController', () => { ); }); + it('does not recalculate a missing folio for a pre-acceptance request payment', async () => { + const requestDb = createMockDb([{ + ...mockPayment, + folioId: null, + bookingRequestId: 'request-001', + }]); + const module = await Test.createTestingModule({ + controllers: [StripeWebhookController], + providers: [ + { provide: DRIZZLE, useValue: requestDb }, + { provide: WebhookService, useValue: mockWebhookService }, + { provide: FolioService, useValue: mockFolioService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + await (module.get(StripeWebhookController) as any) + .handlePaymentIntentSucceeded({ id: 'pi_test_123' }); + + expect(requestDb.update).toHaveBeenCalled(); + expect(mockFolioService.recalculateBalance).not.toHaveBeenCalled(); + }); + it('should skip if payment already captured', async () => { const capturedDb = createMockDb([{ ...mockPayment, status: 'captured' }]); const module = await Test.createTestingModule({ @@ -262,6 +305,79 @@ describe('StripeWebhookController', () => { ); }); + it('converts Stripe refunds with the currency minor-unit exponent', async () => { + const jpyPayment = { + ...mockPayment, + status: 'captured', + method: 'credit_card', + amount: '500.00', + currencyCode: 'JPY', + }; + const capturedDb = createRefundWebhookDb(jpyPayment); + const module = await Test.createTestingModule({ + controllers: [StripeWebhookController], + providers: [ + { provide: DRIZZLE, useValue: capturedDb }, + { provide: WebhookService, useValue: mockWebhookService }, + { provide: FolioService, useValue: mockFolioService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + await (module.get(StripeWebhookController) as any).handleChargeRefunded({ + id: 'ch_jpy_123', + payment_intent: 'pi_test_123', + amount: 500, + amount_refunded: 500, + currency: 'jpy', + }); + + expect(capturedDb.getInsertedValues()).toEqual(expect.objectContaining({ + amount: '-500.00', + currencyCode: 'JPY', + })); + }); + + it('preserves request provenance on a pre-acceptance refund webhook', async () => { + const capturedDb = createRefundWebhookDb({ + ...mockPayment, + folioId: null, + bookingRequestId: 'request-001', + status: 'captured', + method: 'credit_card', + }); + const module = await Test.createTestingModule({ + controllers: [StripeWebhookController], + providers: [ + { provide: DRIZZLE, useValue: capturedDb }, + { provide: WebhookService, useValue: mockWebhookService }, + { provide: FolioService, useValue: mockFolioService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + await (module.get(StripeWebhookController) as any).handleChargeRefunded({ + id: 'ch_request_123', + payment_intent: 'pi_test_123', + amount: 50000, + amount_refunded: 25000, + }); + + expect(capturedDb.getInsertedValues()).toEqual(expect.objectContaining({ + bookingRequestId: 'request-001', + folioId: null, + originalPaymentId: 'pay-001', + })); + expect(capturedDb.getResolutionValues()).toEqual(expect.objectContaining({ + propertyId: 'prop-001', + bookingRequestId: 'request-001', + paymentId: 'pay-001', + type: 'refund', + amount: '250.00', + })); + expect(mockFolioService.recalculateBalance).not.toHaveBeenCalled(); + }); + it('should not update if payment not found', async () => { const emptyDb = createMockDb([]); const module = await Test.createTestingModule({ From 1da9721cd6a946389e3af0834e04fbced06a1b38 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 21:36:39 +0200 Subject: [PATCH 22/87] fix(booking-requests): harden payment recovery --- ...king-request-allocation-reconciler.spec.ts | 121 +++ .../booking-request-allocation-reconciler.ts | 184 +++++ .../booking-request-decision.spec.ts | 52 ++ .../booking-request-money.spec.ts | 9 + .../booking-request/booking-request-money.ts | 8 +- .../booking-request-payment.db.spec.ts | 177 +++++ .../booking-request-payment.service.ts | 691 +++++++++++++++-- .../booking-request-payment.spec.ts | 718 +++++++++++++++++- .../booking-request.service.ts | 24 +- .../interfaces/payment-gateway.interface.ts | 2 + .../payment/payment-legacy-seam.spec.ts | 139 ++++ .../src/modules/payment/payment.controller.ts | 16 +- .../api/src/modules/payment/payment.module.ts | 2 +- .../modules/payment/payment.service.spec.ts | 30 +- .../src/modules/payment/payment.service.ts | 64 +- .../modules/payment/stripe-gateway.spec.ts | 41 +- .../api/src/modules/payment/stripe-gateway.ts | 63 +- ...tripe-saved-payment-method.gateway.spec.ts | 36 + .../stripe-saved-payment-method.gateway.ts | 65 +- .../payment/stripe-webhook.controller.ts | 119 ++- .../modules/payment/stripe-webhook.spec.ts | 173 ++++- .../booking-request-migration-safety.spec.ts | 28 + .../src/booking-request-schema.spec.ts | 31 + ...0023_booking_request_payment_integrity.sql | 116 +++ packages/database/src/push-schema.ts | 106 ++- .../database/src/schema/booking-request.ts | 60 +- packages/database/src/schema/folio.ts | 9 +- 27 files changed, 2909 insertions(+), 175 deletions(-) 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-payment.db.spec.ts create mode 100644 apps/api/src/modules/payment/payment-legacy-seam.spec.ts create mode 100644 packages/database/src/migrations/0023_booking_request_payment_integrity.sql 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..ef62f62d --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-allocation-reconciler.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + auditLogs, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + payments, +} from '@telivityhaip/database'; +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 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().mockResolvedValue(table === auditLogs ? undefined : 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' }), + }), + ])); + }); +}); 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..f4f14419 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-allocation-reconciler.ts @@ -0,0 +1,184 @@ +import Decimal from 'decimal.js'; +import { and, eq } from 'drizzle-orm'; +import { + auditLogs, + bookingRequestInstallments, + bookingRequestPaymentAllocations, + payments, +} from '@telivityhaip/database'; +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, + 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-decision.spec.ts b/apps/api/src/modules/booking-request/booking-request-decision.spec.ts index 63371464..e911548a 100644 --- a/apps/api/src/modules/booking-request/booking-request-decision.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-decision.spec.ts @@ -1036,6 +1036,58 @@ describe('BookingRequestService denial', () => { 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('denies after money is resolved, records actor, and delivers consequences after commit', async () => { const harness = makeHarness(); harness.state.payments.push({ 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 index eaf79ffd..23652a05 100644 --- a/apps/api/src/modules/booking-request/booking-request-money.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-money.spec.ts @@ -46,6 +46,15 @@ describe('booking request money', () => { .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' })) diff --git a/apps/api/src/modules/booking-request/booking-request-money.ts b/apps/api/src/modules/booking-request/booking-request-money.ts index c43bd8ac..24f93602 100644 --- a/apps/api/src/modules/booking-request/booking-request-money.ts +++ b/apps/api/src/modules/booking-request/booking-request-money.ts @@ -26,6 +26,8 @@ export type ResolveInstallmentAmountInput = { 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 = { @@ -86,8 +88,8 @@ function nonNegative(value: MoneyValue, field: string): Decimal { return result; } -function currency(value: Decimal): Decimal { - return value.toDecimalPlaces(2); +function currency(value: Decimal, exponent = 2): Decimal { + return value.toDecimalPlaces(exponent); } function selectedTotal( @@ -155,7 +157,7 @@ export function resolveInstallmentAmount( result = total.times(percentage).div(100); } - result = currency(result); + result = currency(result, input.currencyExponent ?? 2); if (result.lte(0)) { throw new ConflictException('Installment amount must be positive'); } 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..0d88edef --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-payment.db.spec.ts @@ -0,0 +1,177 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { eq } from 'drizzle-orm'; +import { + auditLogs, + bookingRequestInstallments, + bookingRequestPaymentResolutions, + bookingRequests, + payments, + properties, + ratePlans, + roomTypes, +} from '@telivityhaip/database'; +import { describe, expect, it, vi, beforeAll, afterAll } from 'vitest'; +import { BookingRequestPaymentService } from './booking-request-payment.service'; + +const databaseUrl = process.env['PAYMENT_DB_TEST_URL']; +const describeDatabase = databaseUrl ? describe : describe.skip; + +describeDatabase('Booking Request payment PostgreSQL concurrency 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 paymentId = '71000000-0000-4000-a000-000000000005'; + 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' }, + 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(), + }); + }); + + afterAll(async () => { + if (!client) return; + 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(bookingRequests).where(eq(bookingRequests.id, requestId)); + 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 client.end(); + }); + + 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: ' ', + })).rejects.toMatchObject({ + constraint_name: 'booking_request_payment_resolutions_retained_reason_check', + }); + }); +}); 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 index 4bd92420..69687ecf 100644 --- a/apps/api/src/modules/booking-request/booking-request-payment.service.ts +++ b/apps/api/src/modules/booking-request/booking-request-payment.service.ts @@ -4,6 +4,7 @@ import { Inject, Injectable, NotFoundException, + ServiceUnavailableException, } from '@nestjs/common'; import { auditLogs, @@ -20,11 +21,17 @@ 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 { PaymentService } from '../payment/payment.service'; +import { remainingCapturedAmount } from '../payment/payment-ledger'; +import { reconcileBookingRequestPaymentAllocations } from './booking-request-allocation-reconciler'; import { assertAllocationAmount, resolveInstallmentAmount } from './booking-request-money'; import type { AllocateBookingRequestPaymentDto, @@ -60,8 +67,8 @@ export class BookingRequestPaymentService { @Inject(DRIZZLE) private readonly db: any, @Inject(SAVED_PAYMENT_METHOD_GATEWAY) private readonly savedPaymentMethodGateway: SavedPaymentMethodGateway, - @Inject(PaymentService) private readonly paymentService: PaymentService, @Inject(FolioService) private readonly folioService: FolioService, + @Inject(PAYMENT_GATEWAY) private readonly paymentGateway: PaymentGateway, ) {} async listInstallments(bookingRequestId: string, propertyId: string) { @@ -124,6 +131,9 @@ export class BookingRequestPaymentService { 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) @@ -158,6 +168,9 @@ export class BookingRequestPaymentService { 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, @@ -271,17 +284,17 @@ export class BookingRequestPaymentService { return this.db.transaction(async (tx: any) => { const request = await this.findRequest(tx, bookingRequestId, propertyId, true); this.assertNotDenied(request); - const installment = await this.findInstallment( + const payment = await this.findParentPayment( tx, bookingRequestId, - installmentId, + input.paymentId, propertyId, true, ); - const payment = await this.findParentPayment( + const installment = await this.findInstallment( tx, bookingRequestId, - input.paymentId, + installmentId, propertyId, true, ); @@ -290,6 +303,15 @@ export class BookingRequestPaymentService { } const amount = this.positiveMoney(input.amount, request.currencyCode, 'Allocation amount'); const installmentAmount = this.resolvedInstallmentAmount(installment); + const netCaptured = await this.netCapturedAmount( + tx, + bookingRequestId, + propertyId, + payment, + ); + if (netCaptured.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) @@ -299,7 +321,7 @@ export class BookingRequestPaymentService { .reduce((sum, row) => sum.plus(row.amount), new Decimal(0)); assertAllocationAmount({ amount, - movementAmount: payment.amount, + movementAmount: netCaptured, installmentAmount, alreadyAllocatedMovementAmount: paymentAllocated, alreadyAllocatedInstallmentAmount: installmentAllocated, @@ -420,13 +442,34 @@ export class BookingRequestPaymentService { .onConflictDoNothing() .returning(); if (!created) { - const existing = await this.findPaymentByIdempotency(tx, propertyId, idempotencyKey); + 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); + } + } return { payment: existing, request, isNew: false }; } await this.audit(tx, { @@ -448,7 +491,9 @@ export class BookingRequestPaymentService { return { payment: created, request, isNew: true }; }); - if (!prepared.isNew) return this.paymentResponse(prepared.payment); + if (!prepared.isNew && prepared.payment.status !== 'pending') { + return this.paymentResponse(prepared.payment); + } let gatewayResult: Awaited>; try { @@ -460,20 +505,51 @@ export class BookingRequestPaymentService { idempotencyKey, }); } catch (error: unknown) { - gatewayResult = { - success: false, - transactionId: '', - requiresAction: false, - errorMessage: error instanceof Error ? error.message : 'Saved-card charge failed', - }; + 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, + 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', + ); } 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 @@ -516,12 +592,11 @@ export class BookingRequestPaymentService { ? 'Booking request payment captured' : 'Booking request payment failed', }); + if (status === 'captured' && updated.folioId) { + await this.folioService.recalculateBalance(updated.folioId, propertyId, tx); + } return updated; }); - - if (finalized.status === 'captured' && finalized.folioId) { - await this.folioService.recalculateBalance(finalized.folioId, propertyId); - } return this.paymentResponse(finalized); } @@ -534,14 +609,13 @@ export class BookingRequestPaymentService { 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, - `${provider}:${reference}`, - ); + 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( @@ -579,7 +653,14 @@ export class BookingRequestPaymentService { 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); + } return { payment: existing, isNew: false }; } await this.audit(tx, { @@ -601,11 +682,11 @@ export class BookingRequestPaymentService { }, description: 'External booking request payment recorded', }); + if (created.folioId) { + await this.folioService.recalculateBalance(created.folioId, propertyId, tx); + } return { payment: created, isNew: true }; }); - if (result.isNew && result.payment.folioId) { - await this.folioService.recalculateBalance(result.payment.folioId, propertyId); - } return this.paymentResponse(result.payment); } @@ -616,48 +697,138 @@ export class BookingRequestPaymentService { input: RefundBookingRequestPaymentDto, actor?: AuditActor, ) { - const request = await this.findRequest(this.db, bookingRequestId, propertyId); - this.assertNotDenied(request); - const original = await this.findParentPayment( - this.db, - bookingRequestId, - paymentId, - propertyId, - ); - if (!original.idempotencyKey?.startsWith('booking-request-charge:')) { - throw new ConflictException( - 'Externally recorded payments must use the external return operation', + 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); + if (movement.folioId) { + await this.folioService.recalculateBalance(movement.folioId, propertyId, tx); + } + 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, + 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) { + return { + movement: this.paymentResponse(prepared.movement), + resolution: prepared.claim, + }; } - if (!original.gatewayTransactionId || original.method !== 'credit_card') { - throw new ConflictException('Only a captured gateway card payment can be refunded'); + + let gatewayResult: PaymentGatewayResult; + try { + gatewayResult = await this.paymentGateway.refund( + prepared.original.gatewayTransactionId!, + prepared.amount.toNumber(), + { idempotencyKey, currencyCode: prepared.original.currencyCode }, + ); + } 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 amount = this.positiveMoney(input.amount, original.currencyCode, 'Refund amount'); - await this.assertResolutionCapacity( - this.db, - bookingRequestId, - propertyId, - original, - amount, - ); - const idempotencyKey = this.scopedKey('refund', propertyId, input.idempotencyKey); - const movement = await this.paymentService.refundPayment( - paymentId, - propertyId, - amount.toFixed(2), - { idempotencyKey }, - ); - const resolution = await this.recordResolution({ + + if (!gatewayResult.success) { + await this.finalizeFailedRefundClaim({ + bookingRequestId, + propertyId, + paymentId, + resolutionId: prepared.claim.id, + errorMessage: gatewayResult.errorMessage ?? 'Gateway declined the refund', + actor, + }); + throw new ConflictException(`Refund failed: ${gatewayResult.errorMessage ?? 'Gateway declined'}`); + } + + return this.finalizeCapturedRefund({ bookingRequestId, propertyId, paymentId, - type: 'refund', - amount: new Decimal(movement.amount).abs().toFixed(2), - reason: `Gateway refund movement ${movement.id}`, + resolutionId: prepared.claim.id, + idempotencyKey, + gatewayResult, actor, - marker: movement.id, }); - return { movement: this.paymentResponse(movement), resolution }; } async recordExternalReturn( @@ -687,6 +858,7 @@ export class BookingRequestPaymentService { 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), @@ -694,6 +866,10 @@ export class BookingRequestPaymentService { 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, @@ -705,6 +881,9 @@ export class BookingRequestPaymentService { actor, marker: existing.id, }); + if (existing.folioId) { + await this.folioService.recalculateBalance(existing.folioId, propertyId, tx); + } return { movement: existing, resolution, isNew: false }; } await this.assertResolutionCapacity( @@ -719,7 +898,7 @@ export class BookingRequestPaymentService { .values({ propertyId, bookingRequestId, - folioId: original.folioId, + folioId: request.acceptedFolioId, idempotencyKey, method: original.method, status: 'captured', @@ -759,11 +938,18 @@ export class BookingRequestPaymentService { }, description: 'External booking request payment return recorded', }); + await this.reconcileAllocationsForPayment( + tx, + bookingRequestId, + propertyId, + original, + actor, + ); + if (movement.folioId) { + await this.folioService.recalculateBalance(movement.folioId, propertyId, tx); + } return { movement, resolution, isNew: true }; }); - if (result.isNew && result.movement.folioId) { - await this.folioService.recalculateBalance(result.movement.folioId, propertyId); - } return { movement: this.paymentResponse(result.movement), resolution: result.resolution, @@ -781,7 +967,9 @@ export class BookingRequestPaymentService { if (!reason) throw new BadRequestException('A reason is required for retained money'); return this.db.transaction(async (tx: any) => { const request = await this.findRequest(tx, bookingRequestId, propertyId, true); - this.assertNotDenied(request); + if (request.status !== 'pending') { + throw new ConflictException('Money may be retained only for a pending request'); + } const original = await this.findParentPayment( tx, bookingRequestId, @@ -816,6 +1004,315 @@ export class BookingRequestPaymentService { }); } + 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; + 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) + : 'Gateway result unknown'; + await tx + .update(bookingRequestPaymentResolutions) + .set({ + attempts: (claim.attempts ?? 0) + 1, + lastError, + 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, + 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; + 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; + await tx + .update(bookingRequestPaymentResolutions) + .set({ + status: 'failed', + attempts: (claim.attempts ?? 0) + 1, + lastError: input.errorMessage.slice(0, 500), + 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, + 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', + }); + }); + } + + 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); + if (movement.folioId) { + await this.folioService.recalculateBalance(movement.folioId, input.propertyId, tx); + } + 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, + 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, + 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, + 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 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 normalizeInstallment( request: RequestRow, input: CreateBookingRequestInstallmentDto, @@ -842,6 +1339,7 @@ export class BookingRequestPaymentService { total: this.requestTotal(request), fixedAmount, percentage, + currencyExponent: this.currencyExponent(request.currencyCode), }); return { label, @@ -870,6 +1368,11 @@ export class BookingRequestPaymentService { const amount = this.decimal(value, field); if (amount.lte(0)) throw new ConflictException(`${field} must be positive`); const exponent = this.currencyExponent(currencyCode); + if (exponent > 2) { + throw new BadRequestException( + `${currencyCode.toUpperCase()} minor-unit exponent ${exponent} exceeds ledger storage precision`, + ); + } if (amount.decimalPlaces() > exponent) { throw new BadRequestException( `${field} has fractional minor units for ${currencyCode.toUpperCase()}`, @@ -1061,6 +1564,10 @@ export class BookingRequestPaymentService { method: string; reference?: string; originalPaymentId?: string; + provider?: string | null; + processedAt?: Date; + notes?: string | null; + operationPrefix?: string; }, identityLabel: string, ): void { @@ -1070,6 +1577,16 @@ export class BookingRequestPaymentService { || 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 @@ -1095,6 +1612,44 @@ export class BookingRequestPaymentService { row.bookingRequestId === bookingRequestId && row.propertyId === propertyId); } + private async netCapturedAmount( + db: any, + bookingRequestId: string, + propertyId: string, + payment: PaymentRow, + ): Promise { + const rows = await db + .select() + .from(payments) + .where(and( + eq(payments.originalPaymentId, payment.id), + eq(payments.bookingRequestId, bookingRequestId), + eq(payments.propertyId, propertyId), + eq(payments.status, 'captured'), + )); + const children = rows.filter((row: PaymentRow) => + row.originalPaymentId === payment.id + && row.bookingRequestId === bookingRequestId + && row.propertyId === propertyId + && row.status === 'captured'); + return remainingCapturedAmount(payment.amount, children); + } + + 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, @@ -1138,7 +1693,9 @@ export class BookingRequestPaymentService { ): Promise { const resolutions = await this.scopedResolutions(db, bookingRequestId, propertyId); const resolved = resolutions - .filter((row) => row.paymentId === payment.id) + .filter((row) => + row.paymentId === payment.id + && (row.status == null || row.status === 'pending' || row.status === 'completed')) .reduce((sum, row) => sum.plus(row.amount), new Decimal(0)); const remaining = new Decimal(payment.amount).minus(resolved); if (amount.gt(remaining)) { 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 index 99e198b3..e407e8a1 100644 --- a/apps/api/src/modules/booking-request/booking-request-payment.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-payment.spec.ts @@ -254,8 +254,11 @@ function makeHarness(overrides: Partial = {}) { }; }), }; - const canonicalPaymentService = { - refundPayment: vi.fn(), + const refundGateway = { + refund: vi.fn().mockResolvedValue({ + success: true, + transactionId: 're_gateway_1', + }), }; const folioService = { recalculateBalance: vi.fn(), @@ -263,16 +266,16 @@ function makeHarness(overrides: Partial = {}) { const service = new (BookingRequestPaymentService as any)( database.db, gateway, - canonicalPaymentService, folioService, + refundGateway, ) as BookingRequestPaymentService; return { service, state, database, gateway, - canonicalPaymentService, folioService, + refundGateway, gatewayTransactionStates, }; } @@ -391,6 +394,30 @@ describe('BookingRequestPaymentService installments', () => { }, actor)).rejects.toThrow(/due date/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()], @@ -435,6 +462,42 @@ describe('BookingRequestPaymentService installments', () => { expect(harness.database.lockCalls).toBeGreaterThanOrEqual(6); }); + 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('blocks editing and deletion after any amount has been allocated', async () => { const allocated = installment({ allocatedAmount: '1.00', status: 'partial' }); const editHarness = makeHarness({ installments: [allocated] }); @@ -539,6 +602,153 @@ describe('BookingRequestPaymentService saved-card charges', () => { 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', gatewayTransactionId: 'pi_recovered' }); + const keys = harness.gateway.charge.mock.calls.map((call) => call[0].idempotencyKey); + expect(keys).toHaveLength(2); + expect(new Set(keys).size).toBe(1); + }); + + 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({ @@ -605,6 +815,7 @@ describe('BookingRequestPaymentService saved-card charges', () => { expect(harness.folioService.recalculateBalance).toHaveBeenCalledWith( FOLIO_ID, PROPERTY_ID, + expect.anything(), ); }); @@ -626,6 +837,24 @@ describe('BookingRequestPaymentService saved-card charges', () => { 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(/ledger.*precision|unsupported.*BHD/i); + expect(harness.gateway.charge).not.toHaveBeenCalled(); + expect(harness.state.payments).toHaveLength(0); + }); }); describe('BookingRequestPaymentService external movements and denial resolutions', () => { @@ -710,6 +939,58 @@ describe('BookingRequestPaymentService external movements and denial resolutions { ...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(/ledger.*precision|unsupported.*BHD/i); + expect(bhd.state.payments).toHaveLength(0); }); it('records external money after acceptance directly on the linked folio', async () => { @@ -736,6 +1017,42 @@ describe('BookingRequestPaymentService external movements and denial resolutions 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(), ); }); @@ -746,15 +1063,7 @@ describe('BookingRequestPaymentService external movements and denial resolutions gatewayProvider: 'stripe', gatewayTransactionId: 'pi_original', }); - const refund = { - ...capturedPayment(), - id: 'dddddddd-0000-4000-a000-000000000002', - amount: '-35.00', - originalPaymentId: PAYMENT_ID, - idempotencyKey: 'booking-request-refund:partial-1', - }; const harness = makeHarness({ payments: [original] }); - harness.canonicalPaymentService.refundPayment.mockResolvedValueOnce(refund); const result = await harness.service.refund( REQUEST_ID, @@ -763,14 +1072,15 @@ describe('BookingRequestPaymentService external movements and denial resolutions { amount: '35.00', idempotencyKey: 'partial-refund-1' }, actor, ); - expect(harness.canonicalPaymentService.refundPayment).toHaveBeenCalledWith( - PAYMENT_ID, - PROPERTY_ID, - '35.00', - { idempotencyKey: expect.stringContaining('booking-request-refund:') }, + expect(harness.refundGateway.refund).toHaveBeenCalledWith( + 'pi_original', + 35, + expect.objectContaining({ + idempotencyKey: expect.stringContaining('booking-request-refund:'), + currencyCode: 'EUR', + }), ); expect(result.movement).toMatchObject({ - id: refund.id, originalPaymentId: PAYMENT_ID, amount: '-35.00', }); @@ -781,6 +1091,241 @@ describe('BookingRequestPaymentService external movements and denial resolutions }); }); + 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('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('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('does not send an externally recorded payment to the configured gateway refund adapter', async () => { const harness = makeHarness({ payments: [capturedPayment({ method: 'credit_card', gatewayProvider: 'square' })], @@ -792,7 +1337,7 @@ describe('BookingRequestPaymentService external movements and denial resolutions { amount: '10.00', idempotencyKey: 'wrong-refund-path' }, actor, )).rejects.toThrow(/external return/i); - expect(harness.canonicalPaymentService.refundPayment).not.toHaveBeenCalled(); + expect(harness.refundGateway.refund).not.toHaveBeenCalled(); }); it('records partial external returns as negative canonical movements', async () => { @@ -824,6 +1369,118 @@ describe('BookingRequestPaymentService external movements and denial resolutions }); }); + 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('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( @@ -850,6 +1507,29 @@ describe('BookingRequestPaymentService external movements and denial resolutions }); }); + 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()], diff --git a/apps/api/src/modules/booking-request/booking-request.service.ts b/apps/api/src/modules/booking-request/booking-request.service.ts index d2b187ce..2c3c258e 100644 --- a/apps/api/src/modules/booking-request/booking-request.service.ts +++ b/apps/api/src/modules/booking-request/booking-request.service.ts @@ -525,9 +525,31 @@ export class BookingRequestService { row.propertyId === propertyId && row.bookingRequestId === id && row.originalPaymentId == null); + if (scopedMovements.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); - assertDenialMoneyResolved(scopedMovements, scopedResolutions); + if (scopedResolutions.some((row) => row.status === 'pending')) { + throw new ConflictException( + 'Booking request has a pending payment resolution; retry it before denial', + ); + } + assertDenialMoneyResolved( + scopedMovements, + scopedResolutions + .filter((row) => row.status == null || row.status === 'completed') + .map((row) => ({ + id: row.id, + paymentId: row.paymentId, + movementId: row.movementId ?? undefined, + type: row.type, + amount: row.amount, + reason: row.reason, + })), + ); const decidedAt = new Date(); const [updated] = await tx 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..34fbef3e 100644 --- a/apps/api/src/modules/payment/interfaces/payment-gateway.interface.ts +++ b/apps/api/src/modules/payment/interfaces/payment-gateway.interface.ts @@ -11,6 +11,8 @@ export interface PaymentGatewayResult { */ export interface PaymentGatewayCallOptions { idempotencyKey?: string; + /** Required for amount-bearing capture/refund calls outside scale-two currencies. */ + currencyCode?: string; } export interface PaymentGateway { 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..18d3bad5 --- /dev/null +++ b/apps/api/src/modules/payment/payment-legacy-seam.spec.ts @@ -0,0 +1,139 @@ +import { Reflector } from '@nestjs/core'; +import { describe, expect, it, vi } from 'vitest'; +import { PERMISSIONS_KEY } from '../auth/permissions.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 billing permissions for every generic payment read and mutation', () => { + const reflector = new Reflector(); + for (const method of ['listPayments', 'getPaymentById'] as const) { + expect(reflector.get( + PERMISSIONS_KEY, + PaymentController.prototype[method], + )).toEqual(['folios.read']); + } + for (const method of [ + 'recordPayment', + 'authorizePayment', + 'capturePayment', + 'voidPayment', + 'refundPayment', + 'correctPayment', + ] as const) { + expect(reflector.get( + PERMISSIONS_KEY, + PaymentController.prototype[method], + )).toEqual(['folios.manage']); + } + }); + + 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', + gatewayTransactionId: 'pi_public_receipt', + }); + expect(result).not.toHaveProperty('gatewayPaymentToken'); + expect(result).not.toHaveProperty('idempotencyKey'); + 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.controller.ts b/apps/api/src/modules/payment/payment.controller.ts index f967e0ef..399d5d57 100644 --- a/apps/api/src/modules/payment/payment.controller.ts +++ b/apps/api/src/modules/payment/payment.controller.ts @@ -8,7 +8,7 @@ import { ParseUUIDPipe, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; -import { Roles } from '../auth/roles.decorator'; +import { RequirePermissions } from '../auth/permissions.decorator'; import { PaymentService } from './payment.service'; import { CreatePaymentDto } from './dto/create-payment.dto'; import { AuthorizePaymentDto } from './dto/authorize-payment.dto'; @@ -21,7 +21,7 @@ export class PaymentController { constructor(private readonly paymentService: PaymentService) {} @Post() - @Roles('admin', 'general_manager', 'front_desk', 'reservations') + @RequirePermissions('folios.manage') @ApiOperation({ summary: 'Record payment (cash, bank transfer, etc.)' }) @ApiResponse({ status: 201, description: 'Payment recorded' }) recordPayment(@Body() dto: CreatePaymentDto) { @@ -29,7 +29,7 @@ export class PaymentController { } @Post('authorize') - @Roles('admin', 'general_manager', 'front_desk', 'reservations') + @RequirePermissions('folios.manage') @ApiOperation({ summary: 'Authorize card payment (pre-auth)' }) @ApiResponse({ status: 201, description: 'Payment authorized' }) authorizePayment(@Body() dto: AuthorizePaymentDto) { @@ -37,6 +37,7 @@ export class PaymentController { } @Get() + @RequirePermissions('folios.read') @ApiOperation({ summary: 'List payments with filters' }) @ApiResponse({ status: 200, description: 'Paginated list of payments' }) listPayments(@Query() dto: ListPaymentsDto) { @@ -44,6 +45,7 @@ export class PaymentController { } @Get(':id') + @RequirePermissions('folios.read') @ApiOperation({ summary: 'Get payment by ID' }) @ApiResponse({ status: 200, description: 'Payment found' }) @ApiResponse({ status: 404, description: 'Payment not found' }) @@ -56,7 +58,7 @@ export class PaymentController { } @Post(':id/capture') - @Roles('admin', 'general_manager', 'front_desk', 'reservations') + @RequirePermissions('folios.manage') @ApiOperation({ summary: 'Capture authorized payment' }) @ApiResponse({ status: 200, description: 'Payment captured' }) @ApiQuery({ name: 'propertyId', type: String }) @@ -68,7 +70,7 @@ export class PaymentController { } @Post(':id/void') - @Roles('admin', 'general_manager', 'front_desk', 'reservations') + @RequirePermissions('folios.manage') @ApiOperation({ summary: 'Void authorized payment' }) @ApiResponse({ status: 200, description: 'Payment voided' }) @ApiQuery({ name: 'propertyId', type: String }) @@ -80,7 +82,7 @@ export class PaymentController { } @Post(':id/refund') - @Roles('admin', 'general_manager', 'front_desk', 'reservations') + @RequirePermissions('folios.manage') @ApiOperation({ summary: 'Refund captured payment' }) @ApiResponse({ status: 200, description: 'Payment refunded' }) @ApiQuery({ name: 'propertyId', type: String }) @@ -93,7 +95,7 @@ export class PaymentController { } @Post(':id/correct') - @Roles('admin', 'general_manager', 'front_desk', 'reservations') + @RequirePermissions('folios.manage') @ApiOperation({ summary: 'Correct a payment via the void/refund/adjust matrix (KB 14.1)' }) @ApiResponse({ status: 200, description: 'Payment corrected' }) correctPayment( diff --git a/apps/api/src/modules/payment/payment.module.ts b/apps/api/src/modules/payment/payment.module.ts index e17dcc0d..b6176201 100644 --- a/apps/api/src/modules/payment/payment.module.ts +++ b/apps/api/src/modules/payment/payment.module.ts @@ -55,6 +55,6 @@ function createSavedPaymentMethodGateway(configService: ConfigService) { inject: [ConfigService], }, ], - exports: [PaymentService, SAVED_PAYMENT_METHOD_GATEWAY], + 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 7e6460f4..992ac813 100644 --- a/apps/api/src/modules/payment/payment.service.spec.ts +++ b/apps/api/src/modules/payment/payment.service.spec.ts @@ -529,22 +529,14 @@ describe('PaymentService', () => { expect(result).toEqual(refundPayment); }); - it('preserves Booking Request provenance and does not recalculate a missing folio', async () => { + it('rejects a Booking Request refund through the generic service', async () => { const requestPayment = { ...mockPayment, folioId: null, bookingRequestId: 'request-001', status: 'captured', }; - const refundPayment = { - ...mockPayment, - id: 'pay-request-refund', - folioId: null, - bookingRequestId: 'request-001', - amount: '-25.00', - originalPaymentId: 'pay-001', - }; - let insertedValues: Record | undefined; + const insert = vi.fn(); const makeTx = () => { let selectCall = 0; return { @@ -558,12 +550,7 @@ describe('PaymentService', () => { }), })), })), - insert: vi.fn(() => ({ - values: vi.fn((values: Record) => { - insertedValues = values; - return { returning: vi.fn().mockResolvedValue([refundPayment]) }; - }), - })), + insert, }; }; const db = { @@ -579,18 +566,13 @@ describe('PaymentService', () => { ], }).compile(); - await module.get(PaymentService).refundPayment( + await expect(module.get(PaymentService).refundPayment( 'pay-001', 'prop-001', '25.00', - ); + )).rejects.toThrow(/Booking Request payment endpoint/i); - expect(insertedValues).toEqual(expect.objectContaining({ - bookingRequestId: 'request-001', - folioId: null, - originalPaymentId: 'pay-001', - amount: '-25.00', - })); + expect(insert).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 5cef3cb6..89e19b44 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'; @@ -199,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) @@ -235,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) { @@ -264,6 +266,8 @@ export class PaymentService { * 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) @@ -336,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}'`, @@ -426,7 +432,7 @@ export class PaymentService { const result = await this.gateway.refund( original.gatewayTransactionId, refundDec.toNumber(), - { idempotencyKey }, + { idempotencyKey, currencyCode: original.currencyCode }, ); if (!result.success) { @@ -567,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 = @@ -742,6 +749,12 @@ export class PaymentService { } 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) @@ -752,8 +765,46 @@ 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, + gatewayTransactionId: payment.gatewayTransactionId, + 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)); @@ -779,7 +830,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-gateway.spec.ts b/apps/api/src/modules/payment/stripe-gateway.spec.ts index d7d0a4db..6a3f7da7 100644 --- a/apps/api/src/modules/payment/stripe-gateway.spec.ts +++ b/apps/api/src/modules/payment/stripe-gateway.spec.ts @@ -209,15 +209,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..f4889353 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 Stripe's safe integer range`, + ); + } + return value; + } + async authorize( token: string, amount: number, @@ -60,7 +94,7 @@ export class StripeGateway implements PaymentGateway { try { const paymentIntent = await this.stripe.paymentIntents.create( { - amount: Math.round(amount * 100), // Stripe uses cents + amount: this.toLedgerMinorUnits(amount, currency), currency: currency.toLowerCase(), payment_method: token, capture_method: 'manual', @@ -103,7 +137,10 @@ export class StripeGateway implements PaymentGateway { try { const params: Stripe.PaymentIntentCaptureParams = {}; if (amount !== undefined) { - params.amount_to_capture = Math.round(amount * 100); + params.amount_to_capture = this.toLedgerMinorUnits( + amount, + options?.currencyCode ?? 'USD', + ); } const paymentIntent = await this.stripe.paymentIntents.capture( @@ -159,7 +196,7 @@ 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'); } const refund = await this.stripe.refunds.create(params, this.requestOptions(options)); @@ -169,11 +206,21 @@ export class StripeGateway implements PaymentGateway { return { success: true, transactionId: refund.id }; } catch (err: any) { this.logger.error(`Stripe refund failed: ${err.message}`, err.stack); - return { - success: false, - transactionId: transactionId, - errorMessage: err.message ?? 'Refund failed', - }; + if (err instanceof StripeLedgerValidationError || this.isExplicitProviderRejection(err)) { + return { + success: false, + transactionId: transactionId, + 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'; + } } 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 index a32b0a0f..795cabdd 100644 --- 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 @@ -373,4 +373,40 @@ describe('StripeSavedPaymentMethodGateway', () => { 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 index 8d175693..36f4ce4e 100644 --- a/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts +++ b/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts @@ -14,6 +14,8 @@ import type { 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; @@ -137,13 +139,18 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa if (stripePaymentIntent?.status === 'requires_action') { return this.requiresAction(stripePaymentIntent.id); } - - return { - success: false, - transactionId: stripePaymentIntent?.id ?? '', - requiresAction: false, - errorMessage: error instanceof Error ? error.message : 'Stripe charge failed', - }; + if ( + error instanceof SavedPaymentMethodValidationError + || this.isExplicitDecline(error, stripePaymentIntent) + ) { + return { + success: false, + transactionId: stripePaymentIntent?.id ?? '', + requiresAction: false, + errorMessage: this.errorMessage(error), + }; + } + throw error; } } @@ -164,7 +171,9 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa !intlWithSupportedValues.supportedValuesOf || !intlWithSupportedValues.supportedValuesOf('currency').includes(normalized) ) { - throw new Error(`Unsupported ISO-4217 currency code '${currencyCode}'`); + throw new SavedPaymentMethodValidationError( + `Unsupported ISO-4217 currency code '${currencyCode}'`, + ); } return normalized; } @@ -175,15 +184,21 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa currency: currencyCode, }).resolvedOptions().maximumFractionDigits; if (exponent === undefined) { - throw new Error(`Unable to resolve minor-unit exponent for '${currencyCode}'`); + throw new SavedPaymentMethodValidationError( + `Unable to resolve minor-unit exponent for '${currencyCode}'`, + ); } const minorUnits = new Decimal(amount).mul(new Decimal(10).pow(exponent)); if (!minorUnits.isInteger()) { - throw new Error(`Amount '${amount}' ${currencyCode} has fractional minor units`); + throw new SavedPaymentMethodValidationError( + `Amount '${amount}' ${currencyCode} has fractional minor units`, + ); } const value = minorUnits.toNumber(); if (!Number.isSafeInteger(value)) { - throw new Error(`Amount '${amount}' ${currencyCode} exceeds the safe Stripe integer range`); + throw new SavedPaymentMethodValidationError( + `Amount '${amount}' ${currencyCode} exceeds the safe Stripe integer range`, + ); } return value; } @@ -199,6 +214,9 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa if (paymentIntent.status === 'requires_action') { return this.requiresAction(paymentIntent.id); } + if (paymentIntent.status === 'processing') { + throw new Error(`Stripe PaymentIntent '${paymentIntent.id}' result is still processing`); + } return { success: false, transactionId: paymentIntent.id, @@ -225,4 +243,29 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa ? 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 a173c8c6..8df03bcc 100644 --- a/apps/api/src/modules/payment/stripe-webhook.controller.ts +++ b/apps/api/src/modules/payment/stripe-webhook.controller.ts @@ -14,6 +14,7 @@ import { eq, and } from 'drizzle-orm'; import { Decimal } from 'decimal.js'; import { auditLogs, + bookingRequests, bookingRequestPaymentResolutions, payments, } from '@telivityhaip/database'; @@ -22,6 +23,7 @@ import { WebhookService } from '../webhook/webhook.service'; import { FolioService } from '../folio/folio.service'; import { sumRefundChildren } from './payment-ledger'; import Stripe from 'stripe'; +import { reconcileBookingRequestPaymentAllocations } from '../booking-request/booking-request-allocation-reconciler'; /** * Stripe Webhook Controller. @@ -122,8 +124,9 @@ export class StripeWebhookController { } } catch (err: any) { this.logger.error(`Error processing webhook ${event.type}: ${err.message}`, err.stack); - // Return 200 to prevent Stripe retries for processing errors - // The error is logged for manual investigation + // Do not acknowledge an unpersisted financial event. Stripe must retry + // transient failures and operators must see unsupported currencies. + throw err; } return res.status(200).json({ received: true }); @@ -233,6 +236,16 @@ export class StripeWebhookController { const ledgerKey = `stripe_refund:${charge.id}:${stripeRefundedDec.toFixed(2)}`; const recorded = 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 [parent] = await tx .select() .from(payments) @@ -247,12 +260,27 @@ export class StripeWebhookController { if (!parent) return null; const [existingForLedger] = await tx - .select({ id: payments.id }) + .select() .from(payments) .where(eq(payments.gatewayTransactionId, ledgerKey)) .limit(1); if (existingForLedger) { - return null; + if (parent.bookingRequestId) { + await reconcileBookingRequestPaymentAllocations(tx, { + bookingRequestId: parent.bookingRequestId, + propertyId: parent.propertyId, + payment: parent, + }); + } + if (parent.folioId) { + await this.folioService.recalculateBalance(parent.folioId, parent.propertyId, tx); + } + return { + row: existingForLedger, + parent, + deltaDec: new Decimal(0), + replay: true as const, + }; } const existingRefunds = await tx @@ -294,21 +322,64 @@ export class StripeWebhookController { .returning(); if (parent.bookingRequestId) { - const [resolution] = await tx - .insert(bookingRequestPaymentResolutions) - .values({ - propertyId: parent.propertyId, - bookingRequestId: parent.bookingRequestId, - paymentId: parent.id, - type: 'refund', - amount: deltaDec.toFixed(2), + const pendingRows = await tx + .select() + .from(bookingRequestPaymentResolutions) + .where(and( + eq(bookingRequestPaymentResolutions.propertyId, parent.propertyId), + eq(bookingRequestPaymentResolutions.bookingRequestId, parent.bookingRequestId), + eq(bookingRequestPaymentResolutions.paymentId, parent.id), + eq(bookingRequestPaymentResolutions.type, 'refund'), + eq(bookingRequestPaymentResolutions.status, 'pending'), + )) + .for('update'); + const pendingClaim = pendingRows.find((candidate: typeof bookingRequestPaymentResolutions.$inferSelect) => + candidate.propertyId === parent.propertyId + && candidate.bookingRequestId === parent.bookingRequestId + && candidate.paymentId === parent.id + && candidate.type === 'refund' + && candidate.status === 'pending' + && new Decimal(candidate.amount).eq(deltaDec)); + const resolvedAt = new Date(); + let resolution: typeof bookingRequestPaymentResolutions.$inferSelect; + if (pendingClaim) { + const values = { + status: 'completed', + movementId: row.id, reason: `Gateway refund movement ${row.id}`, - resolvedAt: new Date(), - }) - .returning(); + attempts: (pendingClaim.attempts ?? 0) + 1, + lastError: null, + resolvedAt, + updatedAt: resolvedAt, + } as const; + await tx + .update(bookingRequestPaymentResolutions) + .set(values) + .where(and( + eq(bookingRequestPaymentResolutions.id, pendingClaim.id), + eq(bookingRequestPaymentResolutions.propertyId, parent.propertyId), + eq(bookingRequestPaymentResolutions.status, 'pending'), + )); + resolution = { ...pendingClaim, ...values }; + } else { + [resolution] = await tx + .insert(bookingRequestPaymentResolutions) + .values({ + propertyId: parent.propertyId, + bookingRequestId: parent.bookingRequestId, + paymentId: parent.id, + type: 'refund', + status: 'completed', + amount: deltaDec.toFixed(2), + movementId: row.id, + reason: `Gateway refund movement ${row.id}`, + resolvedAt, + }) + .returning(); + } await tx.insert(auditLogs).values({ propertyId: parent.propertyId, - action: 'create', + action: pendingClaim ? 'update' : 'create', entityType: 'booking_request_payment_resolution', entityId: resolution.id, newValue: { @@ -317,7 +388,15 @@ export class StripeWebhookController { type: 'refund', amount: deltaDec.toFixed(2), }, - description: 'Stripe refund resolved Booking Request money', + previousValue: pendingClaim ? { status: 'pending' } : null, + description: pendingClaim + ? 'Stripe refund completed pending Booking Request refund claim' + : 'Stripe refund resolved Booking Request money', + }); + await reconcileBookingRequestPaymentAllocations(tx, { + bookingRequestId: parent.bookingRequestId, + propertyId: parent.propertyId, + payment: parent, }); } @@ -328,6 +407,7 @@ export class StripeWebhookController { }); if (!recorded) return; + if ('replay' in recorded && recorded.replay) return; await this.webhookService.emit( 'payment.refunded', @@ -361,6 +441,11 @@ export class StripeWebhookController { 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( diff --git a/apps/api/src/modules/payment/stripe-webhook.spec.ts b/apps/api/src/modules/payment/stripe-webhook.spec.ts index 8089e57d..ecaa23e4 100644 --- a/apps/api/src/modules/payment/stripe-webhook.spec.ts +++ b/apps/api/src/modules/payment/stripe-webhook.spec.ts @@ -5,9 +5,15 @@ import { WebhookService } from '../webhook/webhook.service'; import { FolioService } from '../folio/folio.service'; import { DRIZZLE } from '../../database/database.module'; import { + bookingRequests, bookingRequestPaymentResolutions, payments, } from '@telivityhaip/database'; +import { reconcileBookingRequestPaymentAllocations } from '../booking-request/booking-request-allocation-reconciler'; + +vi.mock('../booking-request/booking-request-allocation-reconciler', () => ({ + reconcileBookingRequestPaymentAllocations: vi.fn().mockResolvedValue(undefined), +})); const mockPayment = { id: 'pay-001', @@ -23,9 +29,11 @@ function createRefundWebhookDb( payment: any, existingRefunds: any[] = [], existingForLedger: any[] = [], + pendingResolutions: any[] = [], ) { let insertedValues: Record | undefined; let resolutionValues: Record | undefined; + let resolutionUpdate: Record | undefined; return { select: vi.fn().mockImplementation(() => ({ from: vi.fn().mockReturnValue({ @@ -38,8 +46,17 @@ function createRefundWebhookDb( let selectCall = 0; const tx = { select: vi.fn().mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ + from: vi.fn((table: unknown) => ({ where: vi.fn().mockImplementation(() => { + if (table === bookingRequests) { + return { for: vi.fn().mockResolvedValue([{ + id: payment.bookingRequestId, + propertyId: payment.propertyId, + }]) }; + } + if (table === bookingRequestPaymentResolutions) { + return { for: vi.fn().mockResolvedValue(pendingResolutions) }; + } selectCall++; if (selectCall === 1) { return { for: vi.fn().mockResolvedValue([payment]) }; @@ -53,7 +70,7 @@ function createRefundWebhookDb( } return { then: (resolve: any) => resolve(existingRefunds) }; }), - }), + })), })), insert: vi.fn((table: unknown) => ({ values: vi.fn((values: Record) => { @@ -73,12 +90,19 @@ function createRefundWebhookDb( }; }), })), + update: vi.fn((table: unknown) => ({ + set: vi.fn((values: Record) => { + if (table === bookingRequestPaymentResolutions) resolutionUpdate = values; + return { where: vi.fn().mockResolvedValue(undefined) }; + }), + })), }; return fn(tx); }), update: vi.fn(), getInsertedValues: () => insertedValues, getResolutionValues: () => resolutionValues, + getResolutionUpdate: () => resolutionUpdate, }; } @@ -338,6 +362,34 @@ describe('StripeWebhookController', () => { })); }); + it('fails visibly instead of acknowledging a scale-three currency refund', async () => { + const capturedDb = createRefundWebhookDb({ + ...mockPayment, + status: 'captured', + method: 'credit_card', + amount: '1.00', + currencyCode: 'BHD', + }); + const module = await Test.createTestingModule({ + controllers: [StripeWebhookController], + providers: [ + { provide: DRIZZLE, useValue: capturedDb }, + { provide: WebhookService, useValue: mockWebhookService }, + { provide: FolioService, useValue: mockFolioService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + await expect((module.get(StripeWebhookController) as any).handleChargeRefunded({ + id: 'ch_bhd_123', + payment_intent: 'pi_test_123', + amount: 1000, + amount_refunded: 1000, + currency: 'bhd', + })).rejects.toThrow(/ledger.*precision|unsupported.*BHD/i); + expect(capturedDb.transaction).not.toHaveBeenCalled(); + }); + it('preserves request provenance on a pre-acceptance refund webhook', async () => { const capturedDb = createRefundWebhookDb({ ...mockPayment, @@ -378,6 +430,123 @@ describe('StripeWebhookController', () => { expect(mockFolioService.recalculateBalance).not.toHaveBeenCalled(); }); + it('reconciles request allocations inside the refund ledger transaction', async () => { + const requestPayment = { + ...mockPayment, + bookingRequestId: 'request-001', + status: 'captured', + method: 'credit_card', + }; + const capturedDb = createRefundWebhookDb(requestPayment); + const module = await Test.createTestingModule({ + controllers: [StripeWebhookController], + providers: [ + { provide: DRIZZLE, useValue: capturedDb }, + { provide: WebhookService, useValue: mockWebhookService }, + { provide: FolioService, useValue: mockFolioService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + await (module.get(StripeWebhookController) as any).handleChargeRefunded({ + id: 'ch_allocated', + payment_intent: 'pi_test_123', + amount: 50000, + amount_refunded: 25000, + }); + + expect(reconcileBookingRequestPaymentAllocations).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + bookingRequestId: 'request-001', + propertyId: 'prop-001', + payment: expect.objectContaining({ id: 'pay-001' }), + }), + ); + }); + + it('completes a matching pending refund claim instead of double-resolving it', async () => { + const requestPayment = { + ...mockPayment, + bookingRequestId: 'request-001', + status: 'captured', + method: 'credit_card', + }; + const capturedDb = createRefundWebhookDb(requestPayment, [], [], [{ + id: 'pending-resolution-1', + propertyId: 'prop-001', + bookingRequestId: 'request-001', + paymentId: 'pay-001', + type: 'refund', + status: 'pending', + amount: '25.00', + attempts: 1, + }]); + const module = await Test.createTestingModule({ + controllers: [StripeWebhookController], + providers: [ + { provide: DRIZZLE, useValue: capturedDb }, + { provide: WebhookService, useValue: mockWebhookService }, + { provide: FolioService, useValue: mockFolioService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + await (module.get(StripeWebhookController) as any).handleChargeRefunded({ + id: 'ch_pending_claim', + payment_intent: 'pi_test_123', + amount: 50000, + amount_refunded: 2500, + }); + + expect(capturedDb.getResolutionValues()).toBeUndefined(); + expect(capturedDb.getResolutionUpdate()).toEqual(expect.objectContaining({ + status: 'completed', + movementId: 'refund-webhook-1', + })); + }); + + it('repairs allocation and folio consequences when a refund webhook is replayed', async () => { + const requestPayment = { + ...mockPayment, + bookingRequestId: 'request-001', + status: 'captured', + method: 'credit_card', + }; + const capturedDb = createRefundWebhookDb(requestPayment, [], [{ + id: 'refund-webhook-existing', + folioId: 'folio-001', + bookingRequestId: 'request-001', + originalPaymentId: 'pay-001', + }]); + const module = await Test.createTestingModule({ + controllers: [StripeWebhookController], + providers: [ + { provide: DRIZZLE, useValue: capturedDb }, + { provide: WebhookService, useValue: mockWebhookService }, + { provide: FolioService, useValue: mockFolioService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + await (module.get(StripeWebhookController) as any).handleChargeRefunded({ + id: 'ch_replayed', + payment_intent: 'pi_test_123', + amount: 50000, + amount_refunded: 25000, + }); + + expect(reconcileBookingRequestPaymentAllocations).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ payment: expect.objectContaining({ id: 'pay-001' }) }), + ); + expect(mockFolioService.recalculateBalance).toHaveBeenCalledWith( + 'folio-001', + 'prop-001', + expect.anything(), + ); + }); + it('should not update if payment not found', async () => { const emptyDb = createMockDb([]); const module = await Test.createTestingModule({ diff --git a/packages/database/src/booking-request-migration-safety.spec.ts b/packages/database/src/booking-request-migration-safety.spec.ts index 5c2287aa..fa91ab47 100644 --- a/packages/database/src/booking-request-migration-safety.spec.ts +++ b/packages/database/src/booking-request-migration-safety.spec.ts @@ -6,6 +6,10 @@ const migration = readFileSync( 'utf8', ); const pushSchema = readFileSync(new URL('./push-schema.ts', import.meta.url), 'utf8'); +const paymentIntegrityMigration = readFileSync( + new URL('./migrations/0023_booking_request_payment_integrity.sql', import.meta.url), + 'utf8', +); describe('booking request accepted-pricing migration safety', () => { it('fails instead of accepting an already-accepted request without an operational snapshot', () => { @@ -34,3 +38,27 @@ describe('booking request accepted-pricing migration safety', () => { expect(index).toBeGreaterThan(column); }); }); + +describe('booking request payment integrity migration safety', () => { + it('persists recoverable resolution claims in both migration paths', () => { + for (const source of [paymentIntegrityMigration, pushSchema]) { + expect(source).toContain('booking_request_payment_resolutions_property_idempotency_unique'); + expect(source).toContain('ADD COLUMN IF NOT EXISTS operation_fingerprint'); + expect(source).toContain('ADD COLUMN IF NOT EXISTS movement_id'); + expect(source).toContain('ADD COLUMN IF NOT EXISTS last_error'); + expect(source).toContain('booking_request_payment_resolutions_status_check'); + } + }); + + it('adds positive-money, installment-shape, and aggregate ownership constraints', () => { + for (const source of [paymentIntegrityMigration, pushSchema]) { + expect(source).toContain('booking_request_installments_amount_kind_check'); + expect(source).toContain('booking_request_installments_milestone_date_check'); + expect(source).toContain('booking_request_payment_allocations_positive_check'); + expect(source).toContain('booking_request_payment_resolutions_retained_reason_check'); + expect(source).toContain('booking_request_payment_allocations_request_fkey'); + expect(source).toContain('booking_request_payment_resolutions_movement_fkey'); + expect(source).toContain('payments_booking_request_parent_positive_check'); + } + }); +}); diff --git a/packages/database/src/booking-request-schema.spec.ts b/packages/database/src/booking-request-schema.spec.ts index 7d848a5f..8ff3b4fa 100644 --- a/packages/database/src/booking-request-schema.spec.ts +++ b/packages/database/src/booking-request-schema.spec.ts @@ -5,6 +5,8 @@ import { bookingRequestConsequences, bookingRequests, bookingRequestInstallments, + bookingRequestPaymentAllocations, + bookingRequestPaymentResolutions, charges, payments, reservations, @@ -28,6 +30,12 @@ describe('booking request schema', () => { expect(bookingRequestConsequences.lastError).toBeDefined(); expect(bookingRequestConsequences.completedAt).toBeDefined(); expect(bookingRequestInstallments.dueMilestone).toBeDefined(); + expect(bookingRequestPaymentResolutions.status).toBeDefined(); + expect(bookingRequestPaymentResolutions.idempotencyKey).toBeDefined(); + expect(bookingRequestPaymentResolutions.operationFingerprint).toBeDefined(); + expect(bookingRequestPaymentResolutions.movementId).toBeDefined(); + expect(bookingRequestPaymentResolutions.attempts).toBeDefined(); + expect(bookingRequestPaymentResolutions.lastError).toBeDefined(); expect(bookingEngineConfig.bookingMode).toBeDefined(); expect(bookingEngineConfig.paymentMethodCollection).toBeDefined(); expect(payments.bookingRequestId).toBeDefined(); @@ -46,6 +54,29 @@ describe('booking request schema', () => { 'booking_request_consequences_property_request_kind_unique', ); + const installmentChecks = getTableConfig(bookingRequestInstallments) + .checks.map((check) => check.name); + expect(installmentChecks).toEqual(expect.arrayContaining([ + 'booking_request_installments_amount_kind_check', + 'booking_request_installments_milestone_date_check', + 'booking_request_installments_allocated_nonnegative_check', + ])); + const allocationChecks = getTableConfig(bookingRequestPaymentAllocations) + .checks.map((check) => check.name); + expect(allocationChecks).toContain('booking_request_payment_allocations_positive_check'); + const resolutionConfig = getTableConfig(bookingRequestPaymentResolutions); + expect(resolutionConfig.checks.map((check) => check.name)).toEqual(expect.arrayContaining([ + 'booking_request_payment_resolutions_positive_check', + 'booking_request_payment_resolutions_status_check', + 'booking_request_payment_resolutions_retained_reason_check', + ])); + expect(resolutionConfig.indexes.map((index) => index.config.name)).toContain( + 'booking_request_payment_resolutions_property_idempotency_unique', + ); + expect(getTableConfig(payments).checks.map((check) => check.name)).toContain( + 'payments_booking_request_parent_positive_check', + ); + const deliveryIndexNames = getTableConfig(webhookDeliveries) .indexes.map((index) => index.config.name); expect(deliveryIndexNames).toContain( diff --git a/packages/database/src/migrations/0023_booking_request_payment_integrity.sql b/packages/database/src/migrations/0023_booking_request_payment_integrity.sql new file mode 100644 index 00000000..c36e0c16 --- /dev/null +++ b/packages/database/src/migrations/0023_booking_request_payment_integrity.sql @@ -0,0 +1,116 @@ +-- Booking Request payment recovery and aggregate integrity. +-- Pending gateway operations are durable claims: provider I/O is always outside +-- the transaction and replay uses the same property-scoped idempotency key. + +ALTER TABLE booking_request_payment_resolutions + ADD COLUMN IF NOT EXISTS status varchar(20) NOT NULL DEFAULT 'completed', + ADD COLUMN IF NOT EXISTS idempotency_key varchar(255), + ADD COLUMN IF NOT EXISTS operation_fingerprint varchar(64), + ADD COLUMN IF NOT EXISTS movement_id uuid, + ADD COLUMN IF NOT EXISTS attempts integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS last_error text, + ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +ALTER TABLE booking_request_payment_resolutions + ALTER COLUMN resolved_at DROP NOT NULL, + ALTER COLUMN resolved_at DROP DEFAULT; + +CREATE UNIQUE INDEX IF NOT EXISTS booking_request_payment_resolutions_property_idempotency_unique + ON booking_request_payment_resolutions (property_id, idempotency_key); +CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_property_id_unique + ON booking_requests (property_id, id); +CREATE UNIQUE INDEX IF NOT EXISTS booking_request_installments_property_request_id_unique + ON booking_request_installments (property_id, booking_request_id, id); +CREATE UNIQUE INDEX IF NOT EXISTS payments_property_request_id_unique + ON payments (property_id, booking_request_id, id); + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_amount_kind_check') THEN + ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_amount_kind_check + CHECK (((fixed_amount IS NOT NULL AND fixed_amount > 0 AND percentage IS NULL) + OR (fixed_amount IS NULL AND percentage > 0 AND percentage <= 100)) + AND resolved_amount IS NOT NULL AND resolved_amount > 0) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_milestone_date_check') THEN + ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_milestone_date_check + CHECK ((due_milestone = 'date' AND due_date IS NOT NULL) + OR (due_milestone <> 'date' AND due_date IS NULL)) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_allocated_nonnegative_check') THEN + ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_allocated_nonnegative_check + CHECK (allocated_amount >= 0 AND allocated_amount <= resolved_amount) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_allocations_positive_check') THEN + ALTER TABLE booking_request_payment_allocations ADD CONSTRAINT booking_request_payment_allocations_positive_check + CHECK (amount > 0) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_positive_check') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_positive_check + CHECK (amount > 0) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_status_check') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_status_check + CHECK (status IN ('pending', 'completed', 'failed')) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_retained_reason_check') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_retained_reason_check + CHECK (type <> 'retained' OR NULLIF(BTRIM(reason), '') IS NOT NULL) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_booking_request_parent_positive_check') THEN + ALTER TABLE payments ADD CONSTRAINT payments_booking_request_parent_positive_check + CHECK (booking_request_id IS NULL OR original_payment_id IS NOT NULL OR amount > 0) NOT VALID; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_allocations_request_fkey') THEN + ALTER TABLE booking_request_payment_allocations ADD CONSTRAINT booking_request_payment_allocations_request_fkey + FOREIGN KEY (property_id, booking_request_id) + REFERENCES booking_requests(property_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_allocations_payment_fkey') THEN + ALTER TABLE booking_request_payment_allocations ADD CONSTRAINT booking_request_payment_allocations_payment_fkey + FOREIGN KEY (property_id, booking_request_id, payment_id) + REFERENCES payments(property_id, booking_request_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_allocations_installment_fkey') THEN + ALTER TABLE booking_request_payment_allocations ADD CONSTRAINT booking_request_payment_allocations_installment_fkey + FOREIGN KEY (property_id, booking_request_id, installment_id) + REFERENCES booking_request_installments(property_id, booking_request_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_request_fkey') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_request_fkey + FOREIGN KEY (property_id, booking_request_id) + REFERENCES booking_requests(property_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_payment_fkey') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_payment_fkey + FOREIGN KEY (property_id, booking_request_id, payment_id) + REFERENCES payments(property_id, booking_request_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_movement_fkey') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_movement_fkey + FOREIGN KEY (property_id, booking_request_id, movement_id) + REFERENCES payments(property_id, booking_request_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_movement_id_fkey') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_movement_id_fkey + FOREIGN KEY (movement_id) REFERENCES payments(id) NOT VALID; + END IF; +END $$; + +ALTER TABLE booking_request_installments VALIDATE CONSTRAINT booking_request_installments_amount_kind_check; +ALTER TABLE booking_request_installments VALIDATE CONSTRAINT booking_request_installments_milestone_date_check; +ALTER TABLE booking_request_installments VALIDATE CONSTRAINT booking_request_installments_allocated_nonnegative_check; +ALTER TABLE booking_request_payment_allocations VALIDATE CONSTRAINT booking_request_payment_allocations_positive_check; +ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_positive_check; +ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_status_check; +ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_retained_reason_check; +ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_parent_positive_check; +ALTER TABLE booking_request_payment_allocations VALIDATE CONSTRAINT booking_request_payment_allocations_request_fkey; +ALTER TABLE booking_request_payment_allocations VALIDATE CONSTRAINT booking_request_payment_allocations_payment_fkey; +ALTER TABLE booking_request_payment_allocations VALIDATE CONSTRAINT booking_request_payment_allocations_installment_fkey; +ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_request_fkey; +ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_payment_fkey; +ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_movement_fkey; +ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_movement_id_fkey; diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index e0b68dbf..0b7748d5 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -1226,11 +1226,18 @@ async function main() { booking_request_id uuid NOT NULL REFERENCES booking_requests(id), payment_id uuid NOT NULL REFERENCES payments(id), type booking_request_payment_resolution_type NOT NULL, + status varchar(20) NOT NULL DEFAULT 'completed', amount numeric(12,2) NOT NULL, + idempotency_key varchar(255), + operation_fingerprint varchar(64), + movement_id uuid REFERENCES payments(id), reason text, + attempts integer NOT NULL DEFAULT 0, + last_error text, resolved_by uuid, - resolved_at timestamptz NOT NULL DEFAULT now(), - created_at timestamptz NOT NULL DEFAULT now() + resolved_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() )`, `CREATE INDEX IF NOT EXISTS booking_request_payment_resolutions_property_request_idx ON booking_request_payment_resolutions (property_id, booking_request_id)`, `CREATE TABLE IF NOT EXISTS booking_request_email_deliveries ( @@ -1601,6 +1608,101 @@ async function main() { `ALTER TABLE booking_requests ALTER COLUMN submission_fingerprint SET NOT NULL`, `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_property_submission_key_unique ON booking_requests (property_id, submission_idempotency_key)`, `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_setup_intent_unique ON booking_requests (setup_intent_id)`, + `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS status varchar(20) NOT NULL DEFAULT 'completed'`, + `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS idempotency_key varchar(255)`, + `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS operation_fingerprint varchar(64)`, + `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS movement_id uuid`, + `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS attempts integer NOT NULL DEFAULT 0`, + `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS last_error text`, + `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()`, + `ALTER TABLE booking_request_payment_resolutions ALTER COLUMN resolved_at DROP NOT NULL`, + `ALTER TABLE booking_request_payment_resolutions ALTER COLUMN resolved_at DROP DEFAULT`, + `CREATE UNIQUE INDEX IF NOT EXISTS booking_request_payment_resolutions_property_idempotency_unique ON booking_request_payment_resolutions (property_id, idempotency_key)`, + `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_property_id_unique ON booking_requests (property_id, id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS booking_request_installments_property_request_id_unique ON booking_request_installments (property_id, booking_request_id, id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS payments_property_request_id_unique ON payments (property_id, booking_request_id, id)`, + `DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_amount_kind_check') THEN + ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_amount_kind_check + CHECK (((fixed_amount IS NOT NULL AND fixed_amount > 0 AND percentage IS NULL) + OR (fixed_amount IS NULL AND percentage > 0 AND percentage <= 100)) + AND resolved_amount IS NOT NULL AND resolved_amount > 0) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_milestone_date_check') THEN + ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_milestone_date_check + CHECK ((due_milestone = 'date' AND due_date IS NOT NULL) + OR (due_milestone <> 'date' AND due_date IS NULL)) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_allocated_nonnegative_check') THEN + ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_allocated_nonnegative_check + CHECK (allocated_amount >= 0 AND allocated_amount <= resolved_amount) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_allocations_positive_check') THEN + ALTER TABLE booking_request_payment_allocations ADD CONSTRAINT booking_request_payment_allocations_positive_check + CHECK (amount > 0) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_positive_check') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_positive_check + CHECK (amount > 0) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_status_check') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_status_check + CHECK (status IN ('pending', 'completed', 'failed')) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_retained_reason_check') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_retained_reason_check + CHECK (type <> 'retained' OR NULLIF(BTRIM(reason), '') IS NOT NULL) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_booking_request_parent_positive_check') THEN + ALTER TABLE payments ADD CONSTRAINT payments_booking_request_parent_positive_check + CHECK (booking_request_id IS NULL OR original_payment_id IS NOT NULL OR amount > 0) NOT VALID; + END IF; + END $$`, + `DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_allocations_request_fkey') THEN + ALTER TABLE booking_request_payment_allocations ADD CONSTRAINT booking_request_payment_allocations_request_fkey + FOREIGN KEY (property_id, booking_request_id) REFERENCES booking_requests(property_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_allocations_payment_fkey') THEN + ALTER TABLE booking_request_payment_allocations ADD CONSTRAINT booking_request_payment_allocations_payment_fkey + FOREIGN KEY (property_id, booking_request_id, payment_id) REFERENCES payments(property_id, booking_request_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_allocations_installment_fkey') THEN + ALTER TABLE booking_request_payment_allocations ADD CONSTRAINT booking_request_payment_allocations_installment_fkey + FOREIGN KEY (property_id, booking_request_id, installment_id) REFERENCES booking_request_installments(property_id, booking_request_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_request_fkey') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_request_fkey + FOREIGN KEY (property_id, booking_request_id) REFERENCES booking_requests(property_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_payment_fkey') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_payment_fkey + FOREIGN KEY (property_id, booking_request_id, payment_id) REFERENCES payments(property_id, booking_request_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_movement_fkey') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_movement_fkey + FOREIGN KEY (property_id, booking_request_id, movement_id) REFERENCES payments(property_id, booking_request_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_movement_id_fkey') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_movement_id_fkey + FOREIGN KEY (movement_id) REFERENCES payments(id) NOT VALID; + END IF; + END $$`, + `ALTER TABLE booking_request_installments VALIDATE CONSTRAINT booking_request_installments_amount_kind_check`, + `ALTER TABLE booking_request_installments VALIDATE CONSTRAINT booking_request_installments_milestone_date_check`, + `ALTER TABLE booking_request_installments VALIDATE CONSTRAINT booking_request_installments_allocated_nonnegative_check`, + `ALTER TABLE booking_request_payment_allocations VALIDATE CONSTRAINT booking_request_payment_allocations_positive_check`, + `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_positive_check`, + `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_status_check`, + `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_retained_reason_check`, + `ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_parent_positive_check`, + `ALTER TABLE booking_request_payment_allocations VALIDATE CONSTRAINT booking_request_payment_allocations_request_fkey`, + `ALTER TABLE booking_request_payment_allocations VALIDATE CONSTRAINT booking_request_payment_allocations_payment_fkey`, + `ALTER TABLE booking_request_payment_allocations VALIDATE CONSTRAINT booking_request_payment_allocations_installment_fkey`, + `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_request_fkey`, + `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_payment_fkey`, + `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_movement_fkey`, + `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_movement_id_fkey`, `DO $booking_request_accepted_snapshot_precondition$ BEGIN IF EXISTS ( diff --git a/packages/database/src/schema/booking-request.ts b/packages/database/src/schema/booking-request.ts index f91de292..66a4fcde 100644 --- a/packages/database/src/schema/booking-request.ts +++ b/packages/database/src/schema/booking-request.ts @@ -1,4 +1,5 @@ import { + check, date, integer, jsonb, @@ -11,6 +12,7 @@ import { uuid, varchar, } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; import type { BookingFormQuestion } from './booking-engine.js'; import { payments, folios } from './folio.js'; import { properties } from './property.js'; @@ -113,6 +115,8 @@ export const bookingRequests = pgTable('booking_requests', { .on(table.setupIntentId), acceptedReservationUnique: uniqueIndex('booking_requests_accepted_reservation_unique') .on(table.acceptedReservationId), + propertyIdUnique: uniqueIndex('booking_requests_property_id_unique') + .on(table.propertyId, table.id), })); /** @@ -167,7 +171,30 @@ export const bookingRequestInstallments = pgTable('booking_request_installments' status: bookingRequestInstallmentStatusEnum('status').notNull().default('unpaid'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}); +}, (table) => ({ + propertyRequestIdUnique: uniqueIndex('booking_request_installments_property_request_id_unique') + .on(table.propertyId, table.bookingRequestId, table.id), + amountKindCheck: check( + 'booking_request_installments_amount_kind_check', + sql`( + (${table.fixedAmount} is not null and ${table.fixedAmount} > 0 and ${table.percentage} is null) + or + (${table.fixedAmount} is null and ${table.percentage} > 0 and ${table.percentage} <= 100) + ) and ${table.resolvedAmount} is not null and ${table.resolvedAmount} > 0`, + ), + milestoneDateCheck: check( + 'booking_request_installments_milestone_date_check', + sql`( + (${table.dueMilestone} = 'date' and ${table.dueDate} is not null) + or + (${table.dueMilestone} <> 'date' and ${table.dueDate} is null) + )`, + ), + allocatedNonnegativeCheck: check( + 'booking_request_installments_allocated_nonnegative_check', + sql`${table.allocatedAmount} >= 0 and ${table.allocatedAmount} <= ${table.resolvedAmount}`, + ), +})); export const bookingRequestPaymentAllocations = pgTable('booking_request_payment_allocations', { id: uuid('id').primaryKey().defaultRandom(), @@ -180,6 +207,10 @@ export const bookingRequestPaymentAllocations = pgTable('booking_request_payment }, (table) => ({ paymentInstallmentUnique: uniqueIndex('booking_request_payment_allocations_payment_installment_unique') .on(table.paymentId, table.installmentId), + positiveCheck: check( + 'booking_request_payment_allocations_positive_check', + sql`${table.amount} > 0`, + ), })); export const bookingRequestPaymentResolutions = pgTable('booking_request_payment_resolutions', { @@ -188,12 +219,35 @@ export const bookingRequestPaymentResolutions = pgTable('booking_request_payment bookingRequestId: uuid('booking_request_id').notNull().references(() => bookingRequests.id), paymentId: uuid('payment_id').notNull().references(() => payments.id), type: bookingRequestPaymentResolutionTypeEnum('type').notNull(), + status: varchar('status', { length: 20 }).notNull().default('completed'), amount: numeric('amount', { precision: 12, scale: 2 }).notNull(), + idempotencyKey: varchar('idempotency_key', { length: 255 }), + operationFingerprint: varchar('operation_fingerprint', { length: 64 }), + movementId: uuid('movement_id').references(() => payments.id), reason: text('reason'), + attempts: integer('attempts').notNull().default(0), + lastError: text('last_error'), resolvedBy: uuid('resolved_by'), - resolvedAt: timestamp('resolved_at', { withTimezone: true }).notNull().defaultNow(), + resolvedAt: timestamp('resolved_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), -}); + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + propertyIdempotencyKeyUnique: + uniqueIndex('booking_request_payment_resolutions_property_idempotency_unique') + .on(table.propertyId, table.idempotencyKey), + positiveCheck: check( + 'booking_request_payment_resolutions_positive_check', + sql`${table.amount} > 0`, + ), + statusCheck: check( + 'booking_request_payment_resolutions_status_check', + sql`${table.status} in ('pending', 'completed', 'failed')`, + ), + retainedReasonCheck: check( + 'booking_request_payment_resolutions_retained_reason_check', + sql`${table.type} <> 'retained' or length(trim(${table.reason})) > 0`, + ), +})); export const bookingRequestEmailDeliveries = pgTable('booking_request_email_deliveries', { id: uuid('id').primaryKey().defaultRandom(), diff --git a/packages/database/src/schema/folio.ts b/packages/database/src/schema/folio.ts index ab4243c5..9fd7d3a0 100644 --- a/packages/database/src/schema/folio.ts +++ b/packages/database/src/schema/folio.ts @@ -1,4 +1,5 @@ -import { pgTable, uuid, varchar, text, boolean, timestamp, numeric, pgEnum, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { check, pgTable, uuid, varchar, text, boolean, timestamp, numeric, pgEnum, uniqueIndex } from 'drizzle-orm/pg-core'; import { properties } from './property.js'; import { reservations, bookings } from './reservation.js'; import { guests } from './guest.js'; @@ -194,4 +195,10 @@ export const payments = pgTable('payments', { }, (table) => ({ propertyIdempotencyKeyUnique: uniqueIndex('payments_property_idempotency_key_unique') .on(table.propertyId, table.idempotencyKey), + propertyRequestIdUnique: uniqueIndex('payments_property_request_id_unique') + .on(table.propertyId, table.bookingRequestId, table.id), + bookingRequestParentPositiveCheck: check( + 'payments_booking_request_parent_positive_check', + sql`${table.bookingRequestId} is null or ${table.originalPaymentId} is not null or ${table.amount} > 0`, + ), })); From ad5497d4c76de94fd2a7a71a83ee770cbc04738e Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 22:34:03 +0200 Subject: [PATCH 23/87] fix(booking-requests): finalize financial recovery --- .../validation/is-money-string.validator.ts | 7 +- .../booking-request-payment-consequence.ts | 56 ++ .../booking-request-payment.db.spec.ts | 118 +++ .../booking-request-payment.service.ts | 329 ++++++- .../booking-request-payment.spec.ts | 208 +++- .../dto/booking-request-payment.dto.ts | 4 +- .../interfaces/payment-gateway.interface.ts | 9 + .../saved-payment-method-gateway.interface.ts | 3 + .../payment/payment-legacy-seam.spec.ts | 2 +- .../modules/payment/payment.service.spec.ts | 34 +- .../src/modules/payment/payment.service.ts | 17 +- .../payment/stripe-financial-state.spec.ts | 92 ++ .../modules/payment/stripe-financial-state.ts | 98 ++ .../modules/payment/stripe-gateway.spec.ts | 58 ++ .../api/src/modules/payment/stripe-gateway.ts | 38 +- ...tripe-saved-payment-method.gateway.spec.ts | 42 +- .../stripe-saved-payment-method.gateway.ts | 14 +- .../payment/stripe-webhook.controller.ts | 713 +++++++++----- .../modules/payment/stripe-webhook.spec.ts | 906 ++++++++---------- .../src/booking-request-schema.spec.ts | 10 +- ...024_booking_request_financial_recovery.sql | 114 +++ packages/database/src/push-schema.ts | 84 ++ .../database/src/schema/booking-request.ts | 30 +- packages/database/src/schema/folio.ts | 4 + 24 files changed, 2198 insertions(+), 792 deletions(-) create mode 100644 apps/api/src/modules/booking-request/booking-request-payment-consequence.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 packages/database/src/migrations/0024_booking_request_financial_recovery.sql 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/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..ac1da857 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-payment-consequence.ts @@ -0,0 +1,56 @@ +import { bookingRequestConsequences } from '@telivityhaip/database'; + +export type BookingRequestFinancialEvent = + | 'payment.received' + | 'payment.failed' + | 'payment.refunded' + | 'payment.external_returned' + | 'payment.retained'; + +const kindPrefix: Record = { + 'payment.received': 'payment_received', + 'payment.failed': 'payment_failed', + 'payment.refunded': 'payment_refunded', + 'payment.external_returned': 'external_returned', + 'payment.retained': 'payment_retained', +}; + +/** + * 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: any, + 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); + 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(); +} 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 index 0d88edef..81008297 100644 --- 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 @@ -3,6 +3,7 @@ import postgres from 'postgres'; import { eq } from 'drizzle-orm'; import { auditLogs, + bookingRequestConsequences, bookingRequestInstallments, bookingRequestPaymentResolutions, bookingRequests, @@ -23,6 +24,10 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = const ratePlanId = '71000000-0000-4000-a000-000000000003'; const requestId = '71000000-0000-4000-a000-000000000004'; const paymentId = '71000000-0000-4000-a000-000000000005'; + 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; @@ -84,10 +89,54 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = 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' }, + currencyCode: 'EUR', + }); }); afterAll(async () => { if (!client) return; + await db.delete(bookingRequestConsequences) + .where(eq(bookingRequestConsequences.bookingRequestId, requestId)); await db.delete(auditLogs).where(eq(auditLogs.propertyId, propertyId)); await db.delete(bookingRequestPaymentResolutions) .where(eq(bookingRequestPaymentResolutions.bookingRequestId, requestId)); @@ -96,6 +145,10 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = 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(); }); @@ -170,8 +223,73 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = 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', + }); + }); }); 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 index 69687ecf..d2209891 100644 --- a/apps/api/src/modules/booking-request/booking-request-payment.service.ts +++ b/apps/api/src/modules/booking-request/booking-request-payment.service.ts @@ -32,6 +32,7 @@ import { } from '../payment/interfaces/saved-payment-method-gateway.interface'; import { remainingCapturedAmount } from '../payment/payment-ledger'; import { reconcileBookingRequestPaymentAllocations } from './booking-request-allocation-reconciler'; +import { ensureBookingRequestFinancialConsequence } from './booking-request-payment-consequence'; import { assertAllocationAmount, resolveInstallmentAmount } from './booking-request-money'; import type { AllocateBookingRequestPaymentDto, @@ -117,8 +118,10 @@ export class BookingRequestPaymentService { .map((row: PaymentRow) => this.paymentResponse(row)), allocations: allocationRows.filter((row: AllocationRow) => row.propertyId === propertyId && row.bookingRequestId === bookingRequestId), - resolutions: resolutionRows.filter((row: ResolutionRow) => - row.propertyId === propertyId && row.bookingRequestId === bookingRequestId), + resolutions: resolutionRows + .filter((row: ResolutionRow) => + row.propertyId === propertyId && row.bookingRequestId === bookingRequestId) + .map((row: ResolutionRow) => this.resolutionResponse(row)), }; } @@ -470,6 +473,22 @@ export class BookingRequestPaymentService { 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, { @@ -541,6 +560,44 @@ export class BookingRequestPaymentService { ); } + 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, + 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); @@ -592,6 +649,20 @@ export class BookingRequestPaymentService { ? '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); } @@ -661,6 +732,21 @@ export class BookingRequestPaymentService { 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, + externalReference: reference, + }, + }); return { payment: existing, isNew: false }; } await this.audit(tx, { @@ -682,6 +768,21 @@ export class BookingRequestPaymentService { }, 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, + externalReference: reference, + }, + }); if (created.folioId) { await this.folioService.recalculateBalance(created.folioId, propertyId, tx); } @@ -738,6 +839,20 @@ export class BookingRequestPaymentService { 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), + resolutionId: replay.id, + }, + }); return { request, original, amount, claim: replay, movement, terminal: true as const }; } if (replay.status === 'failed') { @@ -783,7 +898,7 @@ export class BookingRequestPaymentService { if (prepared.terminal) { return { movement: this.paymentResponse(prepared.movement), - resolution: prepared.claim, + resolution: this.resolutionResponse(prepared.claim), }; } @@ -792,7 +907,16 @@ export class BookingRequestPaymentService { gatewayResult = await this.paymentGateway.refund( prepared.original.gatewayTransactionId!, prepared.amount.toNumber(), - { idempotencyKey, currencyCode: prepared.original.currencyCode }, + { + idempotencyKey, + currencyCode: prepared.original.currencyCode, + metadata: { + claimId: prepared.claim.id, + propertyId, + bookingRequestId, + paymentId, + }, + }, ); } catch (error: unknown) { await this.recordUnknownResolutionAttempt({ @@ -808,19 +932,42 @@ export class BookingRequestPaymentService { ); } - if (!gatewayResult.success) { + 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, }); throw new ConflictException(`Refund failed: ${gatewayResult.errorMessage ?? 'Gateway declined'}`); } - return this.finalizeCapturedRefund({ + const finalized = await this.finalizeCapturedRefund({ bookingRequestId, propertyId, paymentId, @@ -829,6 +976,10 @@ export class BookingRequestPaymentService { gatewayResult, actor, }); + return { + ...finalized, + resolution: this.resolutionResponse(finalized.resolution), + }; } async recordExternalReturn( @@ -880,10 +1031,26 @@ export class BookingRequestPaymentService { reason: `External return movement ${existing.id}`, actor, marker: existing.id, + movementId: existing.id, }); if (existing.folioId) { await this.folioService.recalculateBalance(existing.folioId, propertyId, tx); } + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.external_returned', + logicalId: existing.id, + propertyId, + bookingRequestId, + entityType: 'payment', + entityId: existing.id, + data: { + folioId: existing.folioId, + originalPaymentId: original.id, + returnAmount: amount.toFixed(2), + resolutionId: resolution.id, + externalReference: reference, + }, + }); return { movement: existing, resolution, isNew: false }; } await this.assertResolutionCapacity( @@ -920,6 +1087,7 @@ export class BookingRequestPaymentService { reason: `External return movement ${movement.id}`, actor, marker: movement.id, + movementId: movement.id, }); await this.audit(tx, { propertyId, @@ -938,6 +1106,21 @@ export class BookingRequestPaymentService { }, description: 'External booking request payment return recorded', }); + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.external_returned', + logicalId: movement.id, + propertyId, + bookingRequestId, + entityType: 'payment', + entityId: movement.id, + data: { + folioId: movement.folioId, + originalPaymentId: original.id, + returnAmount: amount.toFixed(2), + resolutionId: resolution.id, + externalReference: reference, + }, + }); await this.reconcileAllocationsForPayment( tx, bookingRequestId, @@ -952,7 +1135,7 @@ export class BookingRequestPaymentService { }); return { movement: this.paymentResponse(result.movement), - resolution: result.resolution, + resolution: this.resolutionResponse(result.resolution), }; } @@ -965,7 +1148,7 @@ export class BookingRequestPaymentService { ) { const reason = input.reason?.trim(); if (!reason) throw new BadRequestException('A reason is required for retained money'); - return this.db.transaction(async (tx: any) => { + 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'); @@ -984,7 +1167,18 @@ export class BookingRequestPaymentService { && row.type === 'retained' && new Decimal(row.amount).eq(amount) && row.reason?.trim() === reason); - if (existing) return existing; + if (existing) { + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.retained', + logicalId: existing.id, + propertyId, + bookingRequestId, + entityType: 'booking_request_payment_resolution', + entityId: existing.id, + data: { paymentId, amount: existing.amount, reason: existing.reason }, + }); + return existing; + } await this.assertResolutionCapacity( tx, bookingRequestId, @@ -992,7 +1186,7 @@ export class BookingRequestPaymentService { original, amount, ); - return this.ensureResolution(tx, { + const resolution = await this.ensureResolution(tx, { bookingRequestId, propertyId, paymentId, @@ -1001,7 +1195,18 @@ export class BookingRequestPaymentService { reason, actor, }); + await ensureBookingRequestFinancialConsequence(tx, { + event: 'payment.retained', + logicalId: resolution.id, + propertyId, + bookingRequestId, + entityType: 'booking_request_payment_resolution', + entityId: resolution.id, + data: { paymentId, amount: resolution.amount, reason: resolution.reason }, + }); + return resolution; }); + return this.resolutionResponse(resolution); } private operationFingerprint(value: Record): string { @@ -1067,6 +1272,8 @@ export class BookingRequestPaymentService { paymentId: string; resolutionId: string; error: unknown; + providerTransactionId?: string; + providerStatus?: string; actor?: AuditActor; }): Promise { await this.db.transaction(async (tx: any) => { @@ -1095,12 +1302,18 @@ export class BookingRequestPaymentService { if (claim.status !== 'pending') return; const lastError = input.error instanceof Error ? input.error.message.slice(0, 500) - : 'Gateway result unknown'; + : 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( @@ -1127,6 +1340,8 @@ export class BookingRequestPaymentService { paymentId: string; resolutionId: string; errorMessage: string; + providerTransactionId?: string; + providerStatus?: string; actor?: AuditActor; }): Promise { await this.db.transaction(async (tx: any) => { @@ -1159,6 +1374,10 @@ export class BookingRequestPaymentService { 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(), }) @@ -1177,6 +1396,19 @@ export class BookingRequestPaymentService { 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', + providerStatus: input.providerStatus ?? 'failed', + }, + }); }); } @@ -1217,6 +1449,21 @@ export class BookingRequestPaymentService { 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, + resolutionId: claim.id, + providerRefundId: claim.providerTransactionId, + }, + }); return { movement: this.paymentResponse(movement), resolution: claim }; } if (claim.status !== 'pending') { @@ -1247,6 +1494,8 @@ export class BookingRequestPaymentService { .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, @@ -1299,6 +1548,21 @@ export class BookingRequestPaymentService { }, 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, + resolutionId: claim.id, + providerRefundId: input.gatewayResult.transactionId, + }, + }); await this.reconcileAllocationsForPayment( tx, input.bookingRequestId, @@ -1358,8 +1622,8 @@ export class BookingRequestPaymentService { if (amount.decimalPlaces() > 2) { throw new BadRequestException('Installment percentage supports at most two decimal places'); } - if (amount.gte(1000)) { - throw new BadRequestException('Installment percentage exceeds storage precision'); + if (amount.gt(100)) { + throw new BadRequestException('Installment percentage cannot exceed 100'); } return amount; } @@ -1756,6 +2020,7 @@ export class BookingRequestPaymentService { reason?: string; actor?: AuditActor; marker?: string; + movementId?: string; }, ) { if (input.marker) { @@ -1767,7 +2032,19 @@ export class BookingRequestPaymentService { row.paymentId === input.paymentId && row.type === input.type && row.reason?.includes(input.marker!)); - if (existing) return existing; + 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) @@ -1776,7 +2053,9 @@ export class BookingRequestPaymentService { 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(), @@ -1826,7 +2105,9 @@ export class BookingRequestPaymentService { amount: row.amount, currencyCode: row.currencyCode, gatewayProvider: row.gatewayProvider, - gatewayTransactionId: row.gatewayTransactionId, + reference: row.gatewayProvider && row.gatewayProvider !== 'stripe' + ? row.gatewayTransactionId + : null, cardLastFour: row.cardLastFour, cardBrand: row.cardBrand, originalPaymentId: row.originalPaymentId, @@ -1837,6 +2118,24 @@ export class BookingRequestPaymentService { }; } + 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: { 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 index e407e8a1..a639cc86 100644 --- a/apps/api/src/modules/booking-request/booking-request-payment.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-payment.spec.ts @@ -4,6 +4,7 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { auditLogs, + bookingRequestConsequences, bookingRequestInstallments, bookingRequestPaymentAllocations, bookingRequestPaymentResolutions, @@ -38,6 +39,7 @@ type State = { allocations: Array>; resolutions: Array>; audits: Array>; + consequences: Array>; }; function request(overrides: Record = {}) { @@ -106,6 +108,7 @@ function tableRows(state: State, table: unknown): Array> { if (table === bookingRequestPaymentAllocations) return state.allocations; if (table === bookingRequestPaymentResolutions) return state.resolutions; if (table === auditLogs) return state.audits; + if (table === bookingRequestConsequences) return state.consequences; throw new Error('Unexpected table in payment test'); } @@ -160,6 +163,16 @@ function makeDatabase(state: State) { 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'); + } + } sequence += 1; inserted = { id: input['id'] ?? `00000000-0000-4000-a000-${String(sequence).padStart(12, '0')}`, @@ -174,6 +187,8 @@ function makeDatabase(state: State) { 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), @@ -240,6 +255,7 @@ function makeHarness(overrides: Partial = {}) { allocations: [], resolutions: [], audits: [], + consequences: [], ...structuredClone(overrides), }; const database = makeDatabase(state); @@ -319,6 +335,16 @@ describe('Booking Request payment HTTP contract', () => { dueMilestone: 'arrival', }); expect(await validate(validInstallment)).toHaveLength(0); + 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' }], @@ -392,6 +418,11 @@ describe('BookingRequestPaymentService installments', () => { 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 () => { @@ -578,10 +609,13 @@ describe('BookingRequestPaymentService saved-card charges', () => { folioId: null, amount: '80.25', status: 'captured', - gatewayTransactionId: 'pi_saved_1', }); 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:/) }), + ]); }); it('returns the existing result for a stable key without calling the gateway again', async () => { @@ -649,12 +683,36 @@ describe('BookingRequestPaymentService saved-card charges', () => { { amount: '40.00', idempotencyKey: 'timeout-after-capture' }, actor, ); - expect(recovered).toMatchObject({ status: 'captured', gatewayTransactionId: 'pi_recovered' }); + 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: { @@ -792,8 +850,11 @@ describe('BookingRequestPaymentService saved-card charges', () => { actor, ); expect(result.status).toBe('failed'); - expect(result.gatewayTransactionId).toBe(gatewayResult.transactionId); + 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:/) }), + ]); } }); @@ -885,8 +946,13 @@ describe('BookingRequestPaymentService external movements and denial resolutions 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', }], }); @@ -896,6 +962,10 @@ describe('BookingRequestPaymentService external movements and denial resolutions expect(result.movements[0]).not.toHaveProperty('idempotencyKey'); 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('records an exact external payment with processed date/reference and rejects duplicate reference', async () => { @@ -928,10 +998,13 @@ describe('BookingRequestPaymentService external movements and denial resolutions amount: '75.10', processedAt: new Date(input.processedAt), gatewayProvider: 'bank', - gatewayTransactionId: 'wire-abc', + 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:/) }), + ]); await expect(harness.service.recordExternalPayment( REQUEST_ID, @@ -1089,6 +1162,9 @@ describe('BookingRequestPaymentService external movements and denial resolutions type: 'refund', amount: '35.00', }); + expect(harness.state.consequences).toEqual([ + expect.objectContaining({ kind: expect.stringMatching(/^payment_refunded:/) }), + ]); }); it('persists a refund capacity claim before gateway I/O and recovers an unknown result', async () => { @@ -1136,6 +1212,124 @@ describe('BookingRequestPaymentService external movements and denial resolutions 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', @@ -1360,13 +1554,17 @@ describe('BookingRequestPaymentService external movements and denial resolutions originalPaymentId: PAYMENT_ID, amount: '-30.00', status: 'captured', - gatewayTransactionId: 'return-1', + 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(/^external_returned:/) }), + ]); }); it('fingerprints the complete external-return record for exact replay', async () => { 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 index 120359ab..bb566391 100644 --- 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 @@ -46,9 +46,9 @@ export class CreateBookingRequestInstallmentDto { @IsMoneyString() fixedAmount?: string; - @ApiPropertyOptional({ example: '30.00', description: 'Percentage from 0.01 to 999.99' }) + @ApiPropertyOptional({ example: '30.00', description: 'Percentage from 0.01 to 100.00' }) @IsOptional() - @IsMoneyString() + @IsMoneyString({ maximum: '100' }) percentage?: string; @ApiProperty({ enum: BOOKING_REQUEST_INSTALLMENT_MILESTONES }) 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 34fbef3e..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; } @@ -13,6 +15,13 @@ 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 index be15114d..fee43029 100644 --- 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 @@ -23,6 +23,9 @@ 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; }; diff --git a/apps/api/src/modules/payment/payment-legacy-seam.spec.ts b/apps/api/src/modules/payment/payment-legacy-seam.spec.ts index 18d3bad5..e9e0d052 100644 --- a/apps/api/src/modules/payment/payment-legacy-seam.spec.ts +++ b/apps/api/src/modules/payment/payment-legacy-seam.spec.ts @@ -109,10 +109,10 @@ describe('legacy payment HTTP seam', () => { id: requestPayment.id, bookingRequestId: null, amount: '100.00', - gatewayTransactionId: 'pi_public_receipt', }); expect(result).not.toHaveProperty('gatewayPaymentToken'); expect(result).not.toHaveProperty('idempotencyKey'); + expect(result).not.toHaveProperty('gatewayTransactionId'); expect(result).not.toHaveProperty('fingerprint'); }); diff --git a/apps/api/src/modules/payment/payment.service.spec.ts b/apps/api/src/modules/payment/payment.service.spec.ts index 992ac813..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,8 @@ 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 () => { @@ -619,7 +631,12 @@ describe('PaymentService', () => { { idempotencyKey: 'booking-request-refund:stable' }, ); - expect(result).toBe(existingRefund); + expect(result).toMatchObject({ + id: existingRefund.id, + amount: existingRefund.amount, + originalPaymentId: existingRefund.originalPaymentId, + }); + expectSafePublicPayment(result); expect(mockGateway.refund).not.toHaveBeenCalled(); }); @@ -773,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 89e19b44..43f788fa 100644 --- a/apps/api/src/modules/payment/payment.service.ts +++ b/apps/api/src/modules/payment/payment.service.ts @@ -102,7 +102,7 @@ export class PaymentService { dto.propertyId, ); - return payment; + return this.safePaymentResponse(payment); } async authorizePayment(dto: AuthorizePaymentDto) { @@ -182,7 +182,7 @@ export class PaymentService { dto.propertyId, ); - return payment; + return this.safePaymentResponse(payment); } /** @@ -259,7 +259,7 @@ export class PaymentService { propertyId, ); - return claimed; + return this.safePaymentResponse(claimed); } /** @@ -314,7 +314,7 @@ export class PaymentService { propertyId, ); - return claimed; + return this.safePaymentResponse(claimed); } /** @@ -425,7 +425,7 @@ export class PaymentService { refundAmountDec: refundDec, replay, } = prepared; - if (replay) return replay; + if (replay) return this.safePaymentResponse(replay); const idempotencyKey = options.idempotencyKey ?? `ref_${id}_${totalAfterDec.toFixed(2)}`; @@ -551,7 +551,7 @@ export class PaymentService { ); } - return refund.row; + return this.safePaymentResponse(refund.row); } /** @@ -649,7 +649,7 @@ export class PaymentService { { folioId: voided.folioId, status: 'voided' }, propertyId, ); - result = voided; + result = this.safePaymentResponse(voided); } await this.webhookService.emit( 'payment.corrected', @@ -745,7 +745,7 @@ 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) { @@ -787,7 +787,6 @@ export class PaymentService { amount: payment.amount, currencyCode: payment.currencyCode, gatewayProvider: payment.gatewayProvider, - gatewayTransactionId: payment.gatewayTransactionId, cardLastFour: payment.cardLastFour, cardBrand: payment.cardBrand, isPreAuthorization: payment.isPreAuthorization, 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..56274a1c --- /dev/null +++ b/apps/api/src/modules/payment/stripe-financial-state.spec.ts @@ -0,0 +1,92 @@ +import { ConflictException } from '@nestjs/common'; +import { + decidePaymentIntentTransition, + decideRefundTransition, + 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); + }); +}); 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..a2c8424d --- /dev/null +++ b/apps/api/src/modules/payment/stripe-financial-state.ts @@ -0,0 +1,98 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; + +export type PaymentIntentEvent = + | 'succeeded' + | 'payment_failed' + | 'canceled' + | 'requires_action'; + +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 = { + 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 { + 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 6a3f7da7..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', diff --git a/apps/api/src/modules/payment/stripe-gateway.ts b/apps/api/src/modules/payment/stripe-gateway.ts index f4889353..be56dbac 100644 --- a/apps/api/src/modules/payment/stripe-gateway.ts +++ b/apps/api/src/modules/payment/stripe-gateway.ts @@ -198,18 +198,37 @@ export class StripeGateway implements PaymentGateway { if (amount !== undefined) { 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 }; + const providerStatus = this.refundProviderStatus(refund.status); + return { + 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: transactionId, + transactionId: '', + providerStatus: 'failed', errorMessage: err.message ?? 'Refund failed', }; } @@ -223,4 +242,19 @@ export class StripeGateway implements PaymentGateway { || 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 index 795cabdd..790bc590 100644 --- 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 @@ -255,9 +255,30 @@ describe('StripeSavedPaymentMethodGateway', () => { ); }); + 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 }, - { currencyCode: 'BHD', amount: '1.234', expectedMinorUnits: 1234 }, ])( 'uses the ISO-4217 exponent for $currencyCode without losing Decimal exactness', async ({ currencyCode, amount, expectedMinorUnits }) => { @@ -285,7 +306,6 @@ describe('StripeSavedPaymentMethodGateway', () => { it.each([ { currencyCode: 'JPY', amount: '1.5' }, { currencyCode: 'USD', amount: '1.001' }, - { currencyCode: 'BHD', amount: '1.2345' }, ])( 'rejects $amount $currencyCode instead of rounding a fractional minor unit', async ({ currencyCode, amount }) => { @@ -307,6 +327,24 @@ describe('StripeSavedPaymentMethodGateway', () => { }, ); + 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', 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 index 36f4ce4e..e7a50786 100644 --- a/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts +++ b/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts @@ -188,6 +188,11 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa `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( @@ -215,7 +220,14 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa return this.requiresAction(paymentIntent.id); } if (paymentIntent.status === 'processing') { - throw new Error(`Stripe PaymentIntent '${paymentIntent.id}' result is still 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, diff --git a/apps/api/src/modules/payment/stripe-webhook.controller.ts b/apps/api/src/modules/payment/stripe-webhook.controller.ts index 8df03bcc..5f78bd84 100644 --- a/apps/api/src/modules/payment/stripe-webhook.controller.ts +++ b/apps/api/src/modules/payment/stripe-webhook.controller.ts @@ -5,6 +5,7 @@ import { Res, Logger, BadRequestException, + ConflictException, Inject, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -21,9 +22,17 @@ import { import { DRIZZLE } from '../../database/database.module'; import { WebhookService } from '../webhook/webhook.service'; import { FolioService } from '../folio/folio.service'; -import { sumRefundChildren } from './payment-ledger'; import Stripe from 'stripe'; import { reconcileBookingRequestPaymentAllocations } from '../booking-request/booking-request-allocation-reconciler'; +import { ensureBookingRequestFinancialConsequence } from '../booking-request/booking-request-payment-consequence'; +import { + decidePaymentIntentTransition, + decideRefundTransition, + refundCorrelation, + type PaymentIntentEvent, + type PaymentIntentLedgerStatus, + type RefundProviderStatus, +} from './stripe-financial-state'; /** * Stripe Webhook Controller. @@ -35,7 +44,8 @@ import { reconcileBookingRequestPaymentAllocations } from '../booking-request/bo * - payment_intent.succeeded → captured * - payment_intent.payment_failed → failed * - payment_intent.canceled → voided - * - charge.refunded → refunded + * - refund.* → exact claim lifecycle finalization + * - charge.refunded → reconciliation signal only */ @ApiTags('webhooks') @Controller('webhooks/stripe') @@ -115,6 +125,16 @@ export class StripeWebhookController { await this.handlePaymentIntentCanceled(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; @@ -133,90 +153,173 @@ export class StripeWebhookController { } private async handlePaymentIntentSucceeded(pi: Stripe.PaymentIntent) { - const payment = await this.findPaymentByGatewayTransactionId(pi.id); - if (!payment) { - this.logger.warn(`No payment found for PaymentIntent ${pi.id}`); - return; - } - - if (payment.status === 'captured') { - this.logger.debug(`Payment ${payment.id} already captured, skipping`); - return; - } - - await this.db - .update(payments) - .set({ status: 'captured', processedAt: new Date(), updatedAt: new Date() }) - .where(and(eq(payments.id, payment.id), eq(payments.propertyId, payment.propertyId))); - - // Pre-acceptance Booking Request movements do not have a folio yet. - if (payment.folioId) { - await this.folioService.recalculateBalance(payment.folioId, payment.propertyId); - } - - await this.webhookService.emit( - 'payment.received', - 'payment', - payment.id, - { folioId: payment.folioId, status: 'captured', stripeEvent: pi.id }, - payment.propertyId, - ); - - this.logger.log(`Payment ${payment.id} updated to captured via webhook`); + await this.finalizePaymentIntent(pi, 'succeeded'); } private async handlePaymentIntentFailed(pi: Stripe.PaymentIntent) { - const payment = await this.findPaymentByGatewayTransactionId(pi.id); - if (!payment) return; - - if (payment.status === 'failed') return; + await this.finalizePaymentIntent(pi, 'payment_failed'); + } - const errorMessage = pi.last_payment_error?.message ?? 'Payment failed'; + private async handlePaymentIntentCanceled(pi: Stripe.PaymentIntent) { + await this.finalizePaymentIntent(pi, 'canceled'); + } - await this.db - .update(payments) - .set({ status: 'failed', notes: errorMessage, updatedAt: new Date() }) - .where(and(eq(payments.id, payment.id), eq(payments.propertyId, payment.propertyId))); + private async handlePaymentIntentRequiresAction(pi: Stripe.PaymentIntent) { + await this.finalizePaymentIntent(pi, 'requires_action'); + } - if (payment.folioId) { - await this.folioService.recalculateBalance(payment.folioId, payment.propertyId); + private async finalizePaymentIntent(pi: Stripe.PaymentIntent, event: PaymentIntentEvent) { + const initial = await this.findPaymentByGatewayTransactionId(pi.id); + if (!initial) { + this.logger.warn(`No payment found for PaymentIntent ${pi.id}`); + return; } - await this.webhookService.emit( - 'payment.failed', - 'payment', - payment.id, - { folioId: payment.folioId, error: errorMessage, stripeEvent: pi.id }, - payment.propertyId, - ); - - this.logger.log(`Payment ${payment.id} updated to failed via webhook`); - } - - private async handlePaymentIntentCanceled(pi: Stripe.PaymentIntent) { - const payment = await this.findPaymentByGatewayTransactionId(pi.id); - if (!payment) return; + 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'); + const 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 (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 }; + } - if (payment.status === 'voided') return; + 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 this.db - .update(payments) - .set({ status: 'voided', updatedAt: new Date() }) - .where(and(eq(payments.id, payment.id), eq(payments.propertyId, payment.propertyId))); + if (request && decision.action !== 'unexpected') { + 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, + stripePaymentIntentId: pi.id, + }, + }); + } + if (folioId && (current.status === 'captured' || decision.action === 'repair')) { + 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 (payment.folioId) { - await this.folioService.recalculateBalance(payment.folioId, payment.propertyId); + if (outcome.blocked) { + throw new ConflictException( + 'Provider captured the payment after booking request denial; operator reconciliation required', + ); + } + 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, + ); } - - await this.webhookService.emit( - 'payment.failed', - 'payment', - payment.id, - { folioId: payment.folioId, status: 'voided', stripeEvent: pi.id }, - payment.propertyId, - ); - - this.logger.log(`Payment ${payment.id} updated to voided via webhook`); } private async handleChargeRefunded(charge: Stripe.Charge) { @@ -229,13 +332,7 @@ export class StripeWebhookController { const payment = await this.findPaymentByGatewayTransactionId(piId); if (!payment) return; - const stripeRefundedDec = this.fromStripeMinorUnits( - charge.amount_refunded, - charge.currency ?? payment.currencyCode, - ); - const ledgerKey = `stripe_refund:${charge.id}:${stripeRefundedDec.toFixed(2)}`; - - const recorded = await this.db.transaction(async (tx: any) => { + await this.db.transaction(async (tx: any) => { if (payment.bookingRequestId) { await tx .select({ id: bookingRequests.id }) @@ -246,7 +343,7 @@ export class StripeWebhookController { )) .for('update'); } - const [parent] = await tx + const parents = await tx .select() .from(payments) .where( @@ -257,174 +354,313 @@ export class StripeWebhookController { ) .for('update'); - if (!parent) return null; + const parent = parents.find((row: typeof payments.$inferSelect) => + row.id === payment.id && row.propertyId === payment.propertyId); + if (!parent) return; + 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 = parent.bookingRequestId + ? (await tx.select().from(bookingRequests).where(and( + eq(bookingRequests.id, parent.bookingRequestId), + eq(bookingRequests.propertyId, parent.propertyId), + )))[0]?.acceptedFolioId + : null; + const folioId = requestFolio ?? parent.folioId; + if (folioId) { + await this.folioService.recalculateBalance(folioId, parent.propertyId, tx); + } + }); + } - const [existingForLedger] = await tx + private async handleRefundUpdated(refund: Stripe.Refund) { + const correlation = refundCorrelation(refund.metadata); + const providerStatus = this.refundStatus(refund.status); + const result = await this.db.transaction(async (tx: any) => { + const requestRows = await tx .select() - .from(payments) - .where(eq(payments.gatewayTransactionId, ledgerKey)) - .limit(1); - if (existingForLedger) { - if (parent.bookingRequestId) { - await reconcileBookingRequestPaymentAllocations(tx, { - bookingRequestId: parent.bookingRequestId, - propertyId: parent.propertyId, - payment: parent, - }); - } - if (parent.folioId) { - await this.folioService.recalculateBalance(parent.folioId, parent.propertyId, tx); - } - return { - row: existingForLedger, - parent, - deltaDec: new Decimal(0), - replay: true as const, - }; - } + .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 existingRefunds = await tx + const parentRows = await tx .select() .from(payments) - .where( - and( - eq(payments.originalPaymentId, parent.id), - eq(payments.propertyId, parent.propertyId), - ), - ); + .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 alreadyRefundedDec = sumRefundChildren(existingRefunds ?? []); - const deltaDec = stripeRefundedDec.minus(alreadyRefundedDec); + 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'); + } - if (deltaDec.lte(0)) { - this.logger.debug( - `Payment ${parent.id} Stripe refund already recorded (${stripeRefundedDec.toFixed(2)})`, - ); - return null; + 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', providerStatus, stripeRefundId: refund.id }, + }); + return { blocked: false }; } - const [row] = await tx - .insert(payments) - .values({ - folioId: parent.folioId, + if (request.status === 'denied') { + 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, - amount: deltaDec.negated().toFixed(2), - currencyCode: parent.currencyCode, status: 'captured', - originalPaymentId: parent.id, + amount: amount.negated().toFixed(2), + currencyCode: parent.currencyCode, gatewayProvider: parent.gatewayProvider, - gatewayTransactionId: ledgerKey, + gatewayTransactionId: refund.id, + originalPaymentId: parent.id, + notes: `Stripe refund ${refund.id}`, processedAt: new Date(), - notes: `Stripe refund ${charge.id}`, - }) - .returning(); - - if (parent.bookingRequestId) { - const pendingRows = await tx - .select() - .from(bookingRequestPaymentResolutions) - .where(and( - eq(bookingRequestPaymentResolutions.propertyId, parent.propertyId), - eq(bookingRequestPaymentResolutions.bookingRequestId, parent.bookingRequestId), - eq(bookingRequestPaymentResolutions.paymentId, parent.id), - eq(bookingRequestPaymentResolutions.type, 'refund'), - eq(bookingRequestPaymentResolutions.status, 'pending'), - )) - .for('update'); - const pendingClaim = pendingRows.find((candidate: typeof bookingRequestPaymentResolutions.$inferSelect) => - candidate.propertyId === parent.propertyId - && candidate.bookingRequestId === parent.bookingRequestId - && candidate.paymentId === parent.id - && candidate.type === 'refund' - && candidate.status === 'pending' - && new Decimal(candidate.amount).eq(deltaDec)); - const resolvedAt = new Date(); - let resolution: typeof bookingRequestPaymentResolutions.$inferSelect; - if (pendingClaim) { - const values = { - status: 'completed', - movementId: row.id, - reason: `Gateway refund movement ${row.id}`, - attempts: (pendingClaim.attempts ?? 0) + 1, - lastError: null, - resolvedAt, - updatedAt: resolvedAt, - } as const; - await tx - .update(bookingRequestPaymentResolutions) - .set(values) - .where(and( - eq(bookingRequestPaymentResolutions.id, pendingClaim.id), - eq(bookingRequestPaymentResolutions.propertyId, parent.propertyId), - eq(bookingRequestPaymentResolutions.status, 'pending'), - )); - resolution = { ...pendingClaim, ...values }; - } else { - [resolution] = await tx - .insert(bookingRequestPaymentResolutions) - .values({ - propertyId: parent.propertyId, - bookingRequestId: parent.bookingRequestId, - paymentId: parent.id, - type: 'refund', - status: 'completed', - amount: deltaDec.toFixed(2), - movementId: row.id, - reason: `Gateway refund movement ${row.id}`, - resolvedAt, - }) - .returning(); - } - await tx.insert(auditLogs).values({ - propertyId: parent.propertyId, - action: pendingClaim ? 'update' : 'create', - entityType: 'booking_request_payment_resolution', - entityId: resolution.id, - newValue: { - requestId: parent.bookingRequestId, - paymentId: parent.id, - type: 'refund', - amount: deltaDec.toFixed(2), - }, - previousValue: pendingClaim ? { status: 'pending' } : null, - description: pendingClaim - ? 'Stripe refund completed pending Booking Request refund claim' - : 'Stripe refund resolved Booking Request money', - }); - await reconcileBookingRequestPaymentAllocations(tx, { - bookingRequestId: parent.bookingRequestId, - propertyId: parent.propertyId, - payment: parent, - }); + }).returning(); } - - if (parent.folioId) { - await this.folioService.recalculateBalance(parent.folioId, parent.propertyId, tx); + 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); } - return { row, parent, deltaDec }; + 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), + stripeRefundId: refund.id, + 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', }); + } - if (!recorded) return; - if ('replay' in recorded && recorded.replay) return; - - await this.webhookService.emit( - 'payment.refunded', - 'payment', - recorded.row.id, - { - folioId: recorded.parent.folioId, - originalPaymentId: recorded.parent.id, - refundAmount: recorded.deltaDec.toFixed(2), - stripeEvent: charge.id, - }, - recorded.parent.propertyId, - ); - - this.logger.log( - `Payment ${recorded.parent.id} refund child ${recorded.row.id} recorded via webhook (${recorded.deltaDec.toFixed(2)})`, - ); + 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 fromStripeMinorUnits(amount: number, currencyCode: string): Decimal { @@ -456,10 +692,19 @@ export class StripeWebhookController { } private async findPaymentByGatewayTransactionId(transactionId: string) { - const [payment] = await this.db + const candidates = await this.db .select() .from(payments) - .where(eq(payments.gatewayTransactionId, transactionId)); - return payment ?? null; + .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; } } diff --git a/apps/api/src/modules/payment/stripe-webhook.spec.ts b/apps/api/src/modules/payment/stripe-webhook.spec.ts index ecaa23e4..1d8b9420 100644 --- a/apps/api/src/modules/payment/stripe-webhook.spec.ts +++ b/apps/api/src/modules/payment/stripe-webhook.spec.ts @@ -1,568 +1,452 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; -import { StripeWebhookController } from './stripe-webhook.controller'; -import { WebhookService } from '../webhook/webhook.service'; -import { FolioService } from '../folio/folio.service'; -import { DRIZZLE } from '../../database/database.module'; import { - bookingRequests, + auditLogs, + bookingRequestConsequences, bookingRequestPaymentResolutions, + bookingRequests, payments, } from '@telivityhaip/database'; +import { DRIZZLE } from '../../database/database.module'; +import { FolioService } from '../folio/folio.service'; +import { WebhookService } from '../webhook/webhook.service'; import { reconcileBookingRequestPaymentAllocations } from '../booking-request/booking-request-allocation-reconciler'; +import { StripeWebhookController } from './stripe-webhook.controller'; vi.mock('../booking-request/booking-request-allocation-reconciler', () => ({ reconcileBookingRequestPaymentAllocations: vi.fn().mockResolvedValue(undefined), })); -const mockPayment = { - id: 'pay-001', - propertyId: 'prop-001', - folioId: 'folio-001', - status: 'authorized', - amount: '500.00', - currencyCode: 'USD', - gatewayTransactionId: 'pi_test_123', +const PROPERTY_ID = 'aaaaaaaa-0000-4000-a000-000000000001'; +const REQUEST_ID = 'bbbbbbbb-0000-4000-a000-000000000001'; +const PAYMENT_ID = 'cccccccc-0000-4000-a000-000000000001'; +const FOLIO_ID = 'dddddddd-0000-4000-a000-000000000001'; + +type State = { + requests: any[]; + payments: any[]; + resolutions: any[]; + consequences: any[]; + audits: any[]; }; -function createRefundWebhookDb( - payment: any, - existingRefunds: any[] = [], - existingForLedger: any[] = [], - pendingResolutions: any[] = [], -) { - let insertedValues: Record | undefined; - let resolutionValues: Record | undefined; - let resolutionUpdate: Record | undefined; +function request(overrides: Record = {}) { return { - select: vi.fn().mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - then: (resolve: any) => resolve([payment]), - }), - }), - })), - transaction: vi.fn(async (fn: any) => { - let selectCall = 0; - const tx = { - select: vi.fn().mockImplementation(() => ({ - from: vi.fn((table: unknown) => ({ - where: vi.fn().mockImplementation(() => { - if (table === bookingRequests) { - return { for: vi.fn().mockResolvedValue([{ - id: payment.bookingRequestId, - propertyId: payment.propertyId, - }]) }; - } - if (table === bookingRequestPaymentResolutions) { - return { for: vi.fn().mockResolvedValue(pendingResolutions) }; - } - selectCall++; - if (selectCall === 1) { - return { for: vi.fn().mockResolvedValue([payment]) }; - } - if (selectCall === 2) { - return { - limit: vi.fn().mockReturnValue({ - then: (resolve: any) => resolve(existingForLedger), - }), - }; - } - return { then: (resolve: any) => resolve(existingRefunds) }; - }), - })), - })), - insert: vi.fn((table: unknown) => ({ - values: vi.fn((values: Record) => { - if (table === payments) insertedValues = values; - if (table === bookingRequestPaymentResolutions) resolutionValues = values; - return { - returning: vi.fn().mockResolvedValue([ - table === payments - ? { - id: 'refund-webhook-1', - folioId: payment.folioId, - bookingRequestId: payment.bookingRequestId, - originalPaymentId: payment.id, - } - : { id: 'resolution-webhook-1', ...values }, - ]), - }; - }), - })), - update: vi.fn((table: unknown) => ({ - set: vi.fn((values: Record) => { - if (table === bookingRequestPaymentResolutions) resolutionUpdate = values; - return { where: vi.fn().mockResolvedValue(undefined) }; - }), - })), - }; - return fn(tx); - }), - update: vi.fn(), - getInsertedValues: () => insertedValues, - getResolutionValues: () => resolutionValues, - getResolutionUpdate: () => resolutionUpdate, + id: REQUEST_ID, + propertyId: PROPERTY_ID, + status: 'pending', + acceptedFolioId: null, + ...overrides, }; } -function createMockDb(returnData: any[] = [mockPayment]) { +function payment(overrides: Record = {}) { return { - select: vi.fn().mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - then: (resolve: any) => resolve(returnData), - }), - }), - })), - insert: vi.fn().mockReturnValue({ - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue(returnData), - }), - }), - update: vi.fn().mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue(returnData), - }), - }), - }), + id: PAYMENT_ID, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + folioId: null, + method: 'credit_card', + status: 'pending', + amount: '100.00', + currencyCode: 'USD', + gatewayProvider: 'stripe', + gatewayTransactionId: 'pi_request_1', + originalPaymentId: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, }; } -const mockWebhookService = { emit: vi.fn() }; -const mockFolioService = { recalculateBalance: vi.fn().mockResolvedValue(undefined) }; -const mockConfigService = { - get: vi.fn().mockImplementation((key: string, defaultValue?: string) => { - if (key === 'STRIPE_MODE') return 'mock'; - if (key === 'STRIPE_SECRET_KEY') return null; - if (key === 'STRIPE_WEBHOOK_SECRET') return null; - return defaultValue; - }), -}; +function resolution(id: string, overrides: Record = {}) { + return { + id, + propertyId: PROPERTY_ID, + bookingRequestId: REQUEST_ID, + paymentId: PAYMENT_ID, + type: 'refund', + status: 'pending', + amount: '25.00', + idempotencyKey: `booking-request-refund:${id}`, + operationFingerprint: `fingerprint-${id}`, + providerTransactionId: null, + providerStatus: null, + movementId: null, + attempts: 0, + lastError: null, + resolvedAt: null, + ...overrides, + }; +} -describe('StripeWebhookController', () => { - let controller: StripeWebhookController; - let mockDb: ReturnType; +function rowsFor(state: State, table: unknown): any[] { + if (table === bookingRequests) return state.requests; + if (table === payments) return state.payments; + if (table === bookingRequestPaymentResolutions) return state.resolutions; + if (table === bookingRequestConsequences) return state.consequences; + if (table === auditLogs) return state.audits; + throw new Error('Unexpected table in Stripe webhook test'); +} - beforeEach(async () => { - mockDb = createMockDb(); - vi.clearAllMocks(); +function conditionValues(condition: unknown): Array<{ column: string; value: unknown }> { + const found: Array<{ column: string; value: unknown }> = []; + const seen = new WeakSet(); + const visit = (value: unknown) => { + if (typeof value !== 'object' || value === null || seen.has(value)) return; + seen.add(value); + const item = value as any; + if (item.constructor?.name === 'Param' && item.encoder?.name) { + found.push({ column: item.encoder.name, value: item.value }); + return; + } + if (Array.isArray(item)) { + for (const nested of item) visit(nested); + return; + } + if (Array.isArray(item.queryChunks)) { + for (const nested of item.queryChunks) visit(nested); + } + }; + visit(condition); + return found; +} - const module: TestingModule = await Test.createTestingModule({ - controllers: [StripeWebhookController], - providers: [ - { provide: DRIZZLE, useValue: mockDb }, - { provide: WebhookService, useValue: mockWebhookService }, - { provide: FolioService, useValue: mockFolioService }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); +function camel(column: string): string { + return column.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); +} - controller = module.get(StripeWebhookController); - }); +function matches(row: any, condition: unknown): boolean { + return conditionValues(condition).every(({ column, value }) => row[camel(column)] === value); +} - describe('handleWebhook (mock mode)', () => { - it('should return 200 with mode: mock when STRIPE_MODE=mock', async () => { - const mockRes = { - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), +function makeDb(state: State) { + let sequence = 0; + const select = vi.fn(() => { + let table: unknown; + let condition: unknown; + let limit: number | undefined; + const result = () => { + const selected = rowsFor(state, table).filter((row) => !condition || matches(row, condition)); + return structuredClone(limit == null ? selected : selected.slice(0, limit)); + }; + const chain: any = { + from: vi.fn((value: unknown) => { table = value; return chain; }), + where: vi.fn((value: unknown) => { condition = value; return chain; }), + for: vi.fn(async () => result()), + limit: vi.fn((value: number) => { limit = value; return chain; }), + then: (resolve: any, reject: any) => Promise.resolve(result()).then(resolve, reject), + }; + return chain; + }); + const insert = vi.fn((table: unknown) => ({ + values: vi.fn((input: Record) => { + let inserted: any; + let attempted = false; + const perform = (ignoreConflict = false) => { + if (attempted) return inserted ? [structuredClone(inserted)] : []; + attempted = true; + const rows = rowsFor(state, table); + if (table === bookingRequestConsequences && rows.some((row) => + row.propertyId === input['propertyId'] + && row.bookingRequestId === input['bookingRequestId'] + && row.kind === input['kind'])) { + return []; + } + if (table === payments && input['idempotencyKey'] && rows.some((row) => + row.propertyId === input['propertyId'] && row.idempotencyKey === input['idempotencyKey'])) { + if (!ignoreConflict) throw new Error('duplicate payment idempotency'); + return []; + } + sequence += 1; + inserted = { + id: input['id'] ?? `eeeeeeee-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)]; }; - - await controller.handleWebhook({}, mockRes); - - expect(mockRes.status).toHaveBeenCalledWith(200); - expect(mockRes.json).toHaveBeenCalledWith({ received: true, mode: 'mock' }); - }); + const thenable = (ignoreConflict = false) => ({ + returning: vi.fn(async () => perform(ignoreConflict)), + then: (resolve: any, reject: any) => Promise.resolve(perform(ignoreConflict)).then(resolve, reject), + }); + return { + ...thenable(), + onConflictDoNothing: vi.fn(() => thenable(true)), + }; + }), + })); + const update = vi.fn((table: unknown) => ({ + set: vi.fn((values: Record) => ({ + where: vi.fn((condition: unknown) => { + const apply = () => { + const changed = rowsFor(state, table).filter((row) => matches(row, condition)); + for (const row of changed) Object.assign(row, structuredClone(values)); + return structuredClone(changed); + }; + return { + returning: vi.fn(async () => apply()), + then: (resolve: any, reject: any) => Promise.resolve(apply()).then(resolve, reject), + }; + }), + })), + })); + const db: any = { select, insert, update }; + db.transaction = vi.fn(async (callback: (tx: any) => Promise) => { + const snapshot = structuredClone(state); + 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; + } }); + return db; +} - describe('internal handlers', () => { - it('should update payment to captured on payment_intent.succeeded', async () => { - const handler = (controller as any).handlePaymentIntentSucceeded.bind(controller); - await handler({ id: 'pi_test_123' }); - - expect(mockDb.update).toHaveBeenCalled(); - expect(mockWebhookService.emit).toHaveBeenCalledWith( - 'payment.received', - 'payment', - 'pay-001', - expect.objectContaining({ status: 'captured' }), - 'prop-001', - ); - }); - - it('does not recalculate a missing folio for a pre-acceptance request payment', async () => { - const requestDb = createMockDb([{ - ...mockPayment, - folioId: null, - bookingRequestId: 'request-001', - }]); - const module = await Test.createTestingModule({ - controllers: [StripeWebhookController], - providers: [ - { provide: DRIZZLE, useValue: requestDb }, - { provide: WebhookService, useValue: mockWebhookService }, - { provide: FolioService, useValue: mockFolioService }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); - - await (module.get(StripeWebhookController) as any) - .handlePaymentIntentSucceeded({ id: 'pi_test_123' }); - - expect(requestDb.update).toHaveBeenCalled(); - expect(mockFolioService.recalculateBalance).not.toHaveBeenCalled(); - }); - - it('should skip if payment already captured', async () => { - const capturedDb = createMockDb([{ ...mockPayment, status: 'captured' }]); - const module = await Test.createTestingModule({ - controllers: [StripeWebhookController], - providers: [ - { provide: DRIZZLE, useValue: capturedDb }, - { provide: WebhookService, useValue: mockWebhookService }, - { provide: FolioService, useValue: mockFolioService }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); - const ctrl = module.get(StripeWebhookController); +const config = { + get: vi.fn((key: string, fallback?: string) => key === 'STRIPE_MODE' ? 'mock' : fallback), +}; - await (ctrl as any).handlePaymentIntentSucceeded({ id: 'pi_test_123' }); +async function harness(overrides: Partial = {}) { + const state: State = { + requests: [request()], + payments: [payment()], + resolutions: [], + consequences: [], + audits: [], + ...structuredClone(overrides), + }; + const db = makeDb(state); + const webhookService = { emit: vi.fn() }; + const folioService = { recalculateBalance: vi.fn().mockResolvedValue(undefined) }; + const module = await Test.createTestingModule({ + controllers: [StripeWebhookController], + providers: [ + { provide: DRIZZLE, useValue: db }, + { provide: WebhookService, useValue: webhookService }, + { provide: FolioService, useValue: folioService }, + { provide: ConfigService, useValue: config }, + ], + }).compile(); + return { + controller: module.get(StripeWebhookController) as any, + state, + db, + webhookService, + folioService, + }; +} - expect(capturedDb.update).not.toHaveBeenCalled(); - }); +function refundEvent(claimId: string, id: string, status = 'succeeded', amount = 2500) { + return { + id, + status, + amount, + currency: 'usd', + payment_intent: 'pi_request_1', + failure_reason: status === 'failed' ? 'declined' : null, + metadata: { + haip_claim_id: claimId, + haip_property_id: PROPERTY_ID, + haip_booking_request_id: REQUEST_ID, + haip_payment_id: PAYMENT_ID, + }, + }; +} - it('should update payment to failed on payment_intent.payment_failed', async () => { - const handler = (controller as any).handlePaymentIntentFailed.bind(controller); - await handler({ - id: 'pi_test_123', - last_payment_error: { message: 'Card declined' }, - }); +describe('StripeWebhookController financial finalization', () => { + beforeEach(() => vi.clearAllMocks()); - expect(mockDb.update).toHaveBeenCalled(); - expect(mockWebhookService.emit).toHaveBeenCalledWith( - 'payment.failed', - 'payment', - 'pay-001', - expect.objectContaining({ error: 'Card declined' }), - 'prop-001', - ); - }); + it('keeps mock-mode HTTP behavior inert', async () => { + const h = await harness(); + const response = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() }; + await h.controller.handleWebhook({}, response); + expect(response.status).toHaveBeenCalledWith(200); + }); - it('should update payment to voided on payment_intent.canceled', async () => { - const handler = (controller as any).handlePaymentIntentCanceled.bind(controller); - await handler({ id: 'pi_test_123' }); + it('finalizes a pending request PaymentIntent under request→payment locks with fresh folio', async () => { + const h = await harness({ requests: [request({ status: 'accepted', acceptedFolioId: FOLIO_ID })] }); + await h.controller.handlePaymentIntentSucceeded({ id: 'pi_request_1' }); + expect(h.state.payments[0]).toMatchObject({ status: 'captured', folioId: FOLIO_ID }); + expect(h.state.consequences).toEqual([ + expect.objectContaining({ kind: expect.stringMatching(/^payment_received:/), status: 'pending' }), + ]); + expect(h.folioService.recalculateBalance).toHaveBeenCalledWith(FOLIO_ID, PROPERTY_ID, h.db); + expect(h.webhookService.emit).not.toHaveBeenCalled(); + }); - expect(mockDb.update).toHaveBeenCalled(); - expect(mockWebhookService.emit).toHaveBeenCalledWith( - 'payment.failed', - 'payment', - 'pay-001', - expect.objectContaining({ status: 'voided' }), - 'prop-001', - ); + it('repairs a captured replay without duplicating its durable consequence', async () => { + const h = await harness({ + requests: [request({ status: 'accepted', acceptedFolioId: FOLIO_ID })], + payments: [payment({ status: 'captured', folioId: null })], }); + await h.controller.handlePaymentIntentSucceeded({ id: 'pi_request_1' }); + await h.controller.handlePaymentIntentSucceeded({ id: 'pi_request_1' }); + expect(h.state.payments[0]!.folioId).toBe(FOLIO_ID); + expect(h.state.consequences).toHaveLength(1); + expect(h.folioService.recalculateBalance).toHaveBeenCalledTimes(2); + }); - it('should insert a refund child on charge.refunded (full)', async () => { - const capturedDb = createRefundWebhookDb({ ...mockPayment, status: 'captured', method: 'credit_card' }); - const module = await Test.createTestingModule({ - controllers: [StripeWebhookController], - providers: [ - { provide: DRIZZLE, useValue: capturedDb }, - { provide: WebhookService, useValue: mockWebhookService }, - { provide: FolioService, useValue: mockFolioService }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); - const ctrl = module.get(StripeWebhookController); - - await (ctrl as any).handleChargeRefunded({ - id: 'ch_test_123', - payment_intent: 'pi_test_123', - amount: 50000, - amount_refunded: 50000, - }); - - expect(capturedDb.transaction).toHaveBeenCalled(); - expect(capturedDb.update).not.toHaveBeenCalled(); - expect(mockFolioService.recalculateBalance).toHaveBeenCalledWith( - 'folio-001', - 'prop-001', - expect.anything(), - ); - expect(mockWebhookService.emit).toHaveBeenCalledWith( - 'payment.refunded', - 'payment', - 'refund-webhook-1', - expect.objectContaining({ refundAmount: '500.00', originalPaymentId: 'pay-001' }), - 'prop-001', - ); - }); + it.each(['failed', 'voided'] as const)('does not regress terminal %s to captured', async (status) => { + const h = await harness({ payments: [payment({ status })] }); + await h.controller.handlePaymentIntentSucceeded({ id: 'pi_request_1' }); + expect(h.state.payments[0]!.status).toBe(status); + expect(h.state.consequences).toHaveLength(0); + expect(h.state.audits).toEqual(expect.arrayContaining([ + expect.objectContaining({ description: expect.stringMatching(/unexpected/i) }), + ])); + }); - it('should insert a partial refund child on charge.refunded', async () => { - const capturedDb = createRefundWebhookDb({ ...mockPayment, status: 'captured', method: 'credit_card' }); - const module = await Test.createTestingModule({ - controllers: [StripeWebhookController], - providers: [ - { provide: DRIZZLE, useValue: capturedDb }, - { provide: WebhookService, useValue: mockWebhookService }, - { provide: FolioService, useValue: mockFolioService }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); - const ctrl = module.get(StripeWebhookController); + it('durably audits and rejects a capture reported after denial', async () => { + const h = await harness({ requests: [request({ status: 'denied' })] }); + await expect(h.controller.handlePaymentIntentSucceeded({ id: 'pi_request_1' })) + .rejects.toThrow(/denial|denied/i); + expect(h.state.payments[0]!.status).toBe('pending'); + expect(h.state.audits).toHaveLength(1); + }); - await (ctrl as any).handleChargeRefunded({ - id: 'ch_test_123', - payment_intent: 'pi_test_123', - amount: 50000, - amount_refunded: 25000, - }); + it('makes provider failure/requires-action terminal and emits a durable failed consequence', async () => { + for (const method of ['handlePaymentIntentFailed', 'handlePaymentIntentRequiresAction']) { + const h = await harness(); + await h.controller[method]({ id: 'pi_request_1', last_payment_error: { message: 'Declined' } }); + expect(h.state.payments[0]!.status).toBe('failed'); + expect(h.state.consequences[0]!.kind).toMatch(/^payment_failed:/); + await h.controller.handlePaymentIntentSucceeded({ id: 'pi_request_1' }); + expect(h.state.payments[0]!.status).toBe('failed'); + } + }); - expect(capturedDb.transaction).toHaveBeenCalled(); - expect(mockWebhookService.emit).toHaveBeenCalledWith( - 'payment.refunded', - 'payment', - 'refund-webhook-1', - expect.objectContaining({ refundAmount: '250.00' }), - 'prop-001', - ); + it('correlates one of two equal refund claims by UUID and provider refund ID', async () => { + const first = resolution('11111111-0000-4000-a000-000000000001'); + const second = resolution('22222222-0000-4000-a000-000000000002'); + const h = await harness({ resolutions: [first, second] }); + await h.controller.handleRefundUpdated(refundEvent(second.id, 're_second')); + expect(h.state.resolutions.find((row) => row.id === first.id)!.status).toBe('pending'); + expect(h.state.resolutions.find((row) => row.id === second.id)).toMatchObject({ + status: 'completed', + providerTransactionId: 're_second', + providerStatus: 'succeeded', + movementId: expect.any(String), }); + expect(h.state.payments.filter((row) => row.originalPaymentId === PAYMENT_ID)).toEqual([ + expect.objectContaining({ amount: '-25.00', gatewayTransactionId: 're_second' }), + ]); + }); - it('converts Stripe refunds with the currency minor-unit exponent', async () => { - const jpyPayment = { - ...mockPayment, - status: 'captured', - method: 'credit_card', - amount: '500.00', - currencyCode: 'JPY', - }; - const capturedDb = createRefundWebhookDb(jpyPayment); - const module = await Test.createTestingModule({ - controllers: [StripeWebhookController], - providers: [ - { provide: DRIZZLE, useValue: capturedDb }, - { provide: WebhookService, useValue: mockWebhookService }, - { provide: FolioService, useValue: mockFolioService }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); - - await (module.get(StripeWebhookController) as any).handleChargeRefunded({ - id: 'ch_jpy_123', - payment_intent: 'pi_test_123', - amount: 500, - amount_refunded: 500, - currency: 'jpy', - }); + it('handles two 25 refunds out of order and replays without double ledger rows', async () => { + const first = resolution('11111111-0000-4000-a000-000000000001'); + const second = resolution('22222222-0000-4000-a000-000000000002'); + const h = await harness({ resolutions: [first, second] }); + await h.controller.handleRefundUpdated(refundEvent(second.id, 're_second')); + await h.controller.handleRefundUpdated(refundEvent(first.id, 're_first')); + await h.controller.handleRefundUpdated(refundEvent(second.id, 're_second')); + expect(h.state.payments.filter((row) => row.originalPaymentId === PAYMENT_ID)) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ gatewayTransactionId: 're_first', amount: '-25.00' }), + expect.objectContaining({ gatewayTransactionId: 're_second', amount: '-25.00' }), + ])); + expect(h.state.payments.filter((row) => row.originalPaymentId === PAYMENT_ID)).toHaveLength(2); + expect(h.state.consequences.filter((row) => row.kind.startsWith('payment_refunded:'))).toHaveLength(2); + }); - expect(capturedDb.getInsertedValues()).toEqual(expect.objectContaining({ - amount: '-500.00', - currencyCode: 'JPY', - })); + it('records pending refund identity, then completes the same claim/provider refund', async () => { + const claim = resolution('11111111-0000-4000-a000-000000000001'); + const h = await harness({ resolutions: [claim] }); + await h.controller.handleRefundUpdated(refundEvent(claim.id, 're_pending', 'pending')); + expect(h.state.resolutions[0]).toMatchObject({ + status: 'pending', providerTransactionId: 're_pending', providerStatus: 'pending', }); + await h.controller.handleRefundUpdated(refundEvent(claim.id, 're_pending', 'succeeded')); + expect(h.state.resolutions[0]!.status).toBe('completed'); + expect(h.state.payments.filter((row) => row.originalPaymentId === PAYMENT_ID)).toHaveLength(1); + }); - it('fails visibly instead of acknowledging a scale-three currency refund', async () => { - const capturedDb = createRefundWebhookDb({ - ...mockPayment, - status: 'captured', - method: 'credit_card', - amount: '1.00', - currencyCode: 'BHD', - }); - const module = await Test.createTestingModule({ - controllers: [StripeWebhookController], - providers: [ - { provide: DRIZZLE, useValue: capturedDb }, - { provide: WebhookService, useValue: mockWebhookService }, - { provide: FolioService, useValue: mockFolioService }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); + it.each(['failed', 'canceled'] as const)('makes refund %s terminal and ignores late success', async (status) => { + const claim = resolution('11111111-0000-4000-a000-000000000001'); + const h = await harness({ resolutions: [claim] }); + await h.controller.handleRefundUpdated(refundEvent(claim.id, `re_${status}`, status)); + expect(h.state.resolutions[0]!.status).toBe('failed'); + await h.controller.handleRefundUpdated(refundEvent(claim.id, `re_${status}`, 'succeeded')); + expect(h.state.resolutions[0]!.status).toBe('failed'); + expect(h.state.payments.filter((row) => row.originalPaymentId === PAYMENT_ID)).toHaveLength(0); + }); - await expect((module.get(StripeWebhookController) as any).handleChargeRefunded({ - id: 'ch_bhd_123', - payment_intent: 'pi_test_123', - amount: 1000, - amount_refunded: 1000, - currency: 'bhd', - })).rejects.toThrow(/ledger.*precision|unsupported.*BHD/i); - expect(capturedDb.transaction).not.toHaveBeenCalled(); + it('treats cumulative charge.refunded as a reconciliation signal, never a claim match', async () => { + const first = resolution('11111111-0000-4000-a000-000000000001'); + const second = resolution('22222222-0000-4000-a000-000000000002'); + const h = await harness({ resolutions: [first, second] }); + await h.controller.handleChargeRefunded({ + id: 'ch_cumulative', payment_intent: 'pi_request_1', amount_refunded: 5000, + currency: 'usd', refunds: { data: [] }, }); + expect(h.state.resolutions.every((row) => row.status === 'pending')).toBe(true); + expect(h.state.payments).toHaveLength(1); + expect(h.state.audits[0]!.description).toMatch(/reconciliation signal/i); + }); - it('preserves request provenance on a pre-acceptance refund webhook', async () => { - const capturedDb = createRefundWebhookDb({ - ...mockPayment, - folioId: null, - bookingRequestId: 'request-001', - status: 'captured', - method: 'credit_card', - }); - const module = await Test.createTestingModule({ - controllers: [StripeWebhookController], - providers: [ - { provide: DRIZZLE, useValue: capturedDb }, - { provide: WebhookService, useValue: mockWebhookService }, - { provide: FolioService, useValue: mockFolioService }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); - - await (module.get(StripeWebhookController) as any).handleChargeRefunded({ - id: 'ch_request_123', - payment_intent: 'pi_test_123', - amount: 50000, - amount_refunded: 25000, - }); - - expect(capturedDb.getInsertedValues()).toEqual(expect.objectContaining({ - bookingRequestId: 'request-001', - folioId: null, - originalPaymentId: 'pay-001', - })); - expect(capturedDb.getResolutionValues()).toEqual(expect.objectContaining({ - propertyId: 'prop-001', - bookingRequestId: 'request-001', - paymentId: 'pay-001', - type: 'refund', - amount: '250.00', - })); - expect(mockFolioService.recalculateBalance).not.toHaveBeenCalled(); + it('does not complete an exact claim from embedded charge.refunded objects', async () => { + const claim = resolution('11111111-0000-4000-a000-000000000001'); + const h = await harness({ resolutions: [claim] }); + await h.controller.handleChargeRefunded({ + id: 'ch_embedded', + payment_intent: 'pi_request_1', + amount_refunded: 2500, + currency: 'usd', + refunds: { data: [refundEvent(claim.id, 're_embedded')] }, }); - - it('reconciles request allocations inside the refund ledger transaction', async () => { - const requestPayment = { - ...mockPayment, - bookingRequestId: 'request-001', - status: 'captured', - method: 'credit_card', - }; - const capturedDb = createRefundWebhookDb(requestPayment); - const module = await Test.createTestingModule({ - controllers: [StripeWebhookController], - providers: [ - { provide: DRIZZLE, useValue: capturedDb }, - { provide: WebhookService, useValue: mockWebhookService }, - { provide: FolioService, useValue: mockFolioService }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); - - await (module.get(StripeWebhookController) as any).handleChargeRefunded({ - id: 'ch_allocated', - payment_intent: 'pi_test_123', - amount: 50000, - amount_refunded: 25000, - }); - - expect(reconcileBookingRequestPaymentAllocations).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - bookingRequestId: 'request-001', - propertyId: 'prop-001', - payment: expect.objectContaining({ id: 'pay-001' }), - }), - ); + expect(h.state.resolutions[0]).toMatchObject({ + status: 'pending', + providerTransactionId: null, + movementId: null, }); + expect(h.state.payments).toHaveLength(1); + expect(h.state.audits[0]!.description).toMatch(/reconciliation signal/i); + }); - it('completes a matching pending refund claim instead of double-resolving it', async () => { - const requestPayment = { - ...mockPayment, - bookingRequestId: 'request-001', - status: 'captured', - method: 'credit_card', - }; - const capturedDb = createRefundWebhookDb(requestPayment, [], [], [{ - id: 'pending-resolution-1', - propertyId: 'prop-001', - bookingRequestId: 'request-001', - paymentId: 'pay-001', - type: 'refund', - status: 'pending', - amount: '25.00', - attempts: 1, - }]); - const module = await Test.createTestingModule({ - controllers: [StripeWebhookController], - providers: [ - { provide: DRIZZLE, useValue: capturedDb }, - { provide: WebhookService, useValue: mockWebhookService }, - { provide: FolioService, useValue: mockFolioService }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); - - await (module.get(StripeWebhookController) as any).handleChargeRefunded({ - id: 'ch_pending_claim', - payment_intent: 'pi_test_123', - amount: 50000, - amount_refunded: 2500, - }); - - expect(capturedDb.getResolutionValues()).toBeUndefined(); - expect(capturedDb.getResolutionUpdate()).toEqual(expect.objectContaining({ - status: 'completed', - movementId: 'refund-webhook-1', - })); + it('preserves a legacy payment when charge.refunded includes uncorrelated refund objects', async () => { + const legacy = payment({ bookingRequestId: null, folioId: FOLIO_ID }); + const h = await harness({ requests: [], payments: [legacy] }); + await h.controller.handleChargeRefunded({ + id: 'ch_legacy', + payment_intent: 'pi_request_1', + amount_refunded: 2500, + currency: 'usd', + refunds: { + data: [{ + id: 're_legacy', status: 'succeeded', amount: 2500, currency: 'usd', metadata: {}, + }], + }, }); + expect(h.state.payments).toEqual([legacy]); + expect(h.folioService.recalculateBalance).toHaveBeenCalledWith(FOLIO_ID, PROPERTY_ID, h.db); + }); - it('repairs allocation and folio consequences when a refund webhook is replayed', async () => { - const requestPayment = { - ...mockPayment, - bookingRequestId: 'request-001', - status: 'captured', - method: 'credit_card', - }; - const capturedDb = createRefundWebhookDb(requestPayment, [], [{ - id: 'refund-webhook-existing', - folioId: 'folio-001', - bookingRequestId: 'request-001', - originalPaymentId: 'pay-001', - }]); - const module = await Test.createTestingModule({ - controllers: [StripeWebhookController], - providers: [ - { provide: DRIZZLE, useValue: capturedDb }, - { provide: WebhookService, useValue: mockWebhookService }, - { provide: FolioService, useValue: mockFolioService }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); - - await (module.get(StripeWebhookController) as any).handleChargeRefunded({ - id: 'ch_replayed', - payment_intent: 'pi_test_123', - amount: 50000, - amount_refunded: 25000, - }); - - expect(reconcileBookingRequestPaymentAllocations).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ payment: expect.objectContaining({ id: 'pay-001' }) }), - ); - expect(mockFolioService.recalculateBalance).toHaveBeenCalledWith( - 'folio-001', - 'prop-001', - expect.anything(), - ); + it('uses fresh acceptance folio for exact refund finalization and repairs allocation state', async () => { + const claim = resolution('11111111-0000-4000-a000-000000000001'); + const h = await harness({ + requests: [request({ status: 'accepted', acceptedFolioId: FOLIO_ID })], + resolutions: [claim], }); + await h.controller.handleRefundUpdated(refundEvent(claim.id, 're_after_accept')); + expect(h.state.payments.find((row) => row.originalPaymentId === PAYMENT_ID)!.folioId).toBe(FOLIO_ID); + expect(h.folioService.recalculateBalance).toHaveBeenCalledWith(FOLIO_ID, PROPERTY_ID, h.db); + expect(reconcileBookingRequestPaymentAllocations).toHaveBeenCalled(); + }); - it('should not update if payment not found', async () => { - const emptyDb = createMockDb([]); - const module = await Test.createTestingModule({ - controllers: [StripeWebhookController], - providers: [ - { provide: DRIZZLE, useValue: emptyDb }, - { provide: WebhookService, useValue: mockWebhookService }, - { provide: FolioService, useValue: mockFolioService }, - { provide: ConfigService, useValue: mockConfigService }, - ], - }).compile(); - const ctrl = module.get(StripeWebhookController); - - await (ctrl as any).handlePaymentIntentSucceeded({ id: 'pi_unknown' }); - - expect(emptyDb.update).not.toHaveBeenCalled(); - }); + it('rejects missing correlation and scale-three refunds without ledger writes', async () => { + const claim = resolution('11111111-0000-4000-a000-000000000001'); + const h = await harness({ resolutions: [claim] }); + await expect(h.controller.handleRefundUpdated({ + ...refundEvent(claim.id, 're_missing'), metadata: { haip_claim_id: claim.id }, + })).rejects.toThrow(/correlation metadata/i); + await expect(h.controller.handleRefundUpdated({ + ...refundEvent(claim.id, 're_bhd', 'succeeded', 1000), currency: 'bhd', + })).rejects.toThrow(/ledger storage precision/i); + expect(h.state.payments).toHaveLength(1); }); }); diff --git a/packages/database/src/booking-request-schema.spec.ts b/packages/database/src/booking-request-schema.spec.ts index 8ff3b4fa..b31314b3 100644 --- a/packages/database/src/booking-request-schema.spec.ts +++ b/packages/database/src/booking-request-schema.spec.ts @@ -33,6 +33,8 @@ describe('booking request schema', () => { expect(bookingRequestPaymentResolutions.status).toBeDefined(); expect(bookingRequestPaymentResolutions.idempotencyKey).toBeDefined(); expect(bookingRequestPaymentResolutions.operationFingerprint).toBeDefined(); + expect(bookingRequestPaymentResolutions.providerTransactionId).toBeDefined(); + expect(bookingRequestPaymentResolutions.providerStatus).toBeDefined(); expect(bookingRequestPaymentResolutions.movementId).toBeDefined(); expect(bookingRequestPaymentResolutions.attempts).toBeDefined(); expect(bookingRequestPaymentResolutions.lastError).toBeDefined(); @@ -69,12 +71,16 @@ describe('booking request schema', () => { 'booking_request_payment_resolutions_positive_check', 'booking_request_payment_resolutions_status_check', 'booking_request_payment_resolutions_retained_reason_check', + 'booking_request_payment_resolutions_lifecycle_check', ])); expect(resolutionConfig.indexes.map((index) => index.config.name)).toContain( 'booking_request_payment_resolutions_property_idempotency_unique', ); - expect(getTableConfig(payments).checks.map((check) => check.name)).toContain( - 'payments_booking_request_parent_positive_check', + expect(getTableConfig(payments).checks.map((check) => check.name)).toEqual( + expect.arrayContaining([ + 'payments_booking_request_parent_positive_check', + 'payments_booking_request_child_shape_check', + ]), ); const deliveryIndexNames = getTableConfig(webhookDeliveries) diff --git a/packages/database/src/migrations/0024_booking_request_financial_recovery.sql b/packages/database/src/migrations/0024_booking_request_financial_recovery.sql new file mode 100644 index 00000000..a2a8d1be --- /dev/null +++ b/packages/database/src/migrations/0024_booking_request_financial_recovery.sql @@ -0,0 +1,114 @@ +-- Exact provider recovery, financial lifecycle checks, and aggregate ownership. + +ALTER TABLE booking_request_payment_resolutions + ADD COLUMN IF NOT EXISTS provider_transaction_id varchar(255), + ADD COLUMN IF NOT EXISTS provider_status varchar(40); + +-- Recover canonical movement provenance written by the Task 7 release before +-- movement_id became mandatory. The UUID marker was deliberately persisted in +-- reason, and payment ownership/amount are rechecked here before linking it. +UPDATE booking_request_payment_resolutions r +SET movement_id = p.id, + provider_transaction_id = CASE + WHEN r.type = 'refund' THEN COALESCE(r.provider_transaction_id, p.gateway_transaction_id) + ELSE r.provider_transaction_id + END, + provider_status = CASE + WHEN r.type = 'refund' THEN COALESCE(r.provider_status, 'succeeded') + ELSE r.provider_status + END +FROM payments p +WHERE r.status = 'completed' + AND r.type IN ('refund', 'external_return') + AND r.movement_id IS NULL + AND p.property_id = r.property_id + AND p.booking_request_id = r.booking_request_id + AND p.original_payment_id = r.payment_id + AND r.reason LIKE '%' || p.id::text || '%'; + +UPDATE booking_request_payment_resolutions r +SET provider_transaction_id = COALESCE(r.provider_transaction_id, p.gateway_transaction_id), + provider_status = COALESCE(r.provider_status, 'succeeded') +FROM payments p +WHERE r.type = 'refund' + AND r.status = 'completed' + AND r.movement_id = p.id; + +UPDATE booking_request_payment_resolutions +SET resolved_at = COALESCE(resolved_at, updated_at, created_at) +WHERE status IN ('completed', 'failed'); + +DO $booking_request_resolution_provenance$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM booking_request_payment_resolutions + WHERE status = 'completed' + AND type IN ('refund', 'external_return') + AND movement_id IS NULL + ) THEN + RAISE EXCEPTION 'Completed Booking Request refund/return lacks canonical movement provenance'; + END IF; +END +$booking_request_resolution_provenance$; + +CREATE UNIQUE INDEX IF NOT EXISTS br_payment_resolutions_property_provider_tx_unique + ON booking_request_payment_resolutions (property_id, provider_transaction_id); + +ALTER TABLE booking_request_payment_resolutions + DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_retained_reason_check, + DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_lifecycle_check; + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_retained_reason_check') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_retained_reason_check + CHECK (type <> 'retained' OR (reason IS NOT NULL AND NULLIF(BTRIM(reason), '') IS NOT NULL)) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_lifecycle_check') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_lifecycle_check + CHECK ( + (status = 'pending' AND type = 'refund' + AND idempotency_key IS NOT NULL AND operation_fingerprint IS NOT NULL + AND resolved_at IS NULL AND movement_id IS NULL) + OR + (status = 'failed' AND type = 'refund' + AND idempotency_key IS NOT NULL AND operation_fingerprint IS NOT NULL + AND resolved_at IS NOT NULL AND movement_id IS NULL) + OR + (status = 'completed' AND resolved_at IS NOT NULL AND ( + (type IN ('refund', 'external_return') AND movement_id IS NOT NULL) + OR + (type = 'retained' AND movement_id IS NULL) + )) + ) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_booking_request_child_shape_check') THEN + ALTER TABLE payments ADD CONSTRAINT payments_booking_request_child_shape_check + CHECK (booking_request_id IS NULL OR original_payment_id IS NULL OR (amount < 0 AND status = 'captured')) NOT VALID; + END IF; +END $$; + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_request_fkey') THEN + ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_request_fkey + FOREIGN KEY (property_id, booking_request_id) + REFERENCES booking_requests(property_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_booking_request_fkey') THEN + ALTER TABLE payments ADD CONSTRAINT payments_booking_request_fkey + FOREIGN KEY (property_id, booking_request_id) + REFERENCES booking_requests(property_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_booking_request_parent_fkey') THEN + ALTER TABLE payments ADD CONSTRAINT payments_booking_request_parent_fkey + FOREIGN KEY (property_id, booking_request_id, original_payment_id) + REFERENCES payments(property_id, booking_request_id, id) NOT VALID; + END IF; +END $$; + +ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_retained_reason_check; +ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_lifecycle_check; +ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_child_shape_check; +ALTER TABLE booking_request_installments VALIDATE CONSTRAINT booking_request_installments_request_fkey; +ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_fkey; +ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_parent_fkey; diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index 0b7748d5..9fc416a2 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -1230,6 +1230,8 @@ async function main() { amount numeric(12,2) NOT NULL, idempotency_key varchar(255), operation_fingerprint varchar(64), + provider_transaction_id varchar(255), + provider_status varchar(40), movement_id uuid REFERENCES payments(id), reason text, attempts integer NOT NULL DEFAULT 0, @@ -1611,6 +1613,8 @@ async function main() { `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS status varchar(20) NOT NULL DEFAULT 'completed'`, `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS idempotency_key varchar(255)`, `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS operation_fingerprint varchar(64)`, + `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS provider_transaction_id varchar(255)`, + `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS provider_status varchar(40)`, `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS movement_id uuid`, `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS attempts integer NOT NULL DEFAULT 0`, `ALTER TABLE booking_request_payment_resolutions ADD COLUMN IF NOT EXISTS last_error text`, @@ -1703,6 +1707,86 @@ async function main() { `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_payment_fkey`, `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_movement_fkey`, `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_movement_id_fkey`, + `UPDATE booking_request_payment_resolutions r + SET movement_id = p.id, + provider_transaction_id = CASE WHEN r.type = 'refund' THEN COALESCE(r.provider_transaction_id, p.gateway_transaction_id) ELSE r.provider_transaction_id END, + provider_status = CASE WHEN r.type = 'refund' THEN COALESCE(r.provider_status, 'succeeded') ELSE r.provider_status END + FROM payments p + WHERE r.status = 'completed' + AND r.type IN ('refund', 'external_return') + AND r.movement_id IS NULL + AND p.property_id = r.property_id + AND p.booking_request_id = r.booking_request_id + AND p.original_payment_id = r.payment_id + AND r.reason LIKE '%' || p.id::text || '%'`, + `UPDATE booking_request_payment_resolutions r + SET provider_transaction_id = COALESCE(r.provider_transaction_id, p.gateway_transaction_id), + provider_status = COALESCE(r.provider_status, 'succeeded') + FROM payments p + WHERE r.type = 'refund' AND r.status = 'completed' AND r.movement_id = p.id`, + `UPDATE booking_request_payment_resolutions + SET resolved_at = COALESCE(resolved_at, updated_at, created_at) + WHERE status IN ('completed', 'failed')`, + `DO $booking_request_resolution_provenance$ + BEGIN + IF EXISTS ( + SELECT 1 FROM booking_request_payment_resolutions + WHERE status = 'completed' AND type IN ('refund', 'external_return') AND movement_id IS NULL + ) THEN + RAISE EXCEPTION 'Completed Booking Request refund/return lacks canonical movement provenance'; + END IF; + END + $booking_request_resolution_provenance$`, + `CREATE UNIQUE INDEX IF NOT EXISTS br_payment_resolutions_property_provider_tx_unique + ON booking_request_payment_resolutions (property_id, provider_transaction_id)`, + `ALTER TABLE booking_request_payment_resolutions + DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_retained_reason_check, + DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_lifecycle_check`, + `DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_retained_reason_check') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_retained_reason_check + CHECK (type <> 'retained' OR (reason IS NOT NULL AND NULLIF(BTRIM(reason), '') IS NOT NULL)) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_lifecycle_check') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_lifecycle_check + CHECK ( + (status = 'pending' AND type = 'refund' + AND idempotency_key IS NOT NULL AND operation_fingerprint IS NOT NULL + AND resolved_at IS NULL AND movement_id IS NULL) + OR (status = 'failed' AND type = 'refund' + AND idempotency_key IS NOT NULL AND operation_fingerprint IS NOT NULL + AND resolved_at IS NOT NULL AND movement_id IS NULL) + OR (status = 'completed' AND resolved_at IS NOT NULL AND ( + (type IN ('refund', 'external_return') AND movement_id IS NOT NULL) + OR (type = 'retained' AND movement_id IS NULL) + )) + ) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_booking_request_child_shape_check') THEN + ALTER TABLE payments ADD CONSTRAINT payments_booking_request_child_shape_check + CHECK (booking_request_id IS NULL OR original_payment_id IS NULL OR (amount < 0 AND status = 'captured')) NOT VALID; + END IF; + END $$`, + `DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_request_fkey') THEN + ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_request_fkey + FOREIGN KEY (property_id, booking_request_id) REFERENCES booking_requests(property_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_booking_request_fkey') THEN + ALTER TABLE payments ADD CONSTRAINT payments_booking_request_fkey + FOREIGN KEY (property_id, booking_request_id) REFERENCES booking_requests(property_id, id) NOT VALID; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_booking_request_parent_fkey') THEN + ALTER TABLE payments ADD CONSTRAINT payments_booking_request_parent_fkey + FOREIGN KEY (property_id, booking_request_id, original_payment_id) REFERENCES payments(property_id, booking_request_id, id) NOT VALID; + END IF; + END $$`, + `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_retained_reason_check`, + `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_lifecycle_check`, + `ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_child_shape_check`, + `ALTER TABLE booking_request_installments VALIDATE CONSTRAINT booking_request_installments_request_fkey`, + `ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_fkey`, + `ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_parent_fkey`, `DO $booking_request_accepted_snapshot_precondition$ BEGIN IF EXISTS ( diff --git a/packages/database/src/schema/booking-request.ts b/packages/database/src/schema/booking-request.ts index 66a4fcde..115c9d43 100644 --- a/packages/database/src/schema/booking-request.ts +++ b/packages/database/src/schema/booking-request.ts @@ -130,6 +130,11 @@ export type BookingRequestConsequenceKind = | 'denied_event' | 'reservation_created_event' | 'folio_created_event' + | `payment_received:${string}` + | `payment_failed:${string}` + | `payment_refunded:${string}` + | `external_returned:${string}` + | `payment_retained:${string}` | `service:${string}`; export type BookingRequestConsequenceStatus = 'pending' | 'processing' | 'completed'; @@ -223,6 +228,8 @@ export const bookingRequestPaymentResolutions = pgTable('booking_request_payment amount: numeric('amount', { precision: 12, scale: 2 }).notNull(), idempotencyKey: varchar('idempotency_key', { length: 255 }), operationFingerprint: varchar('operation_fingerprint', { length: 64 }), + providerTransactionId: varchar('provider_transaction_id', { length: 255 }), + providerStatus: varchar('provider_status', { length: 40 }), movementId: uuid('movement_id').references(() => payments.id), reason: text('reason'), attempts: integer('attempts').notNull().default(0), @@ -235,6 +242,9 @@ export const bookingRequestPaymentResolutions = pgTable('booking_request_payment propertyIdempotencyKeyUnique: uniqueIndex('booking_request_payment_resolutions_property_idempotency_unique') .on(table.propertyId, table.idempotencyKey), + propertyProviderTransactionUnique: + uniqueIndex('br_payment_resolutions_property_provider_tx_unique') + .on(table.propertyId, table.providerTransactionId), positiveCheck: check( 'booking_request_payment_resolutions_positive_check', sql`${table.amount} > 0`, @@ -245,7 +255,25 @@ export const bookingRequestPaymentResolutions = pgTable('booking_request_payment ), retainedReasonCheck: check( 'booking_request_payment_resolutions_retained_reason_check', - sql`${table.type} <> 'retained' or length(trim(${table.reason})) > 0`, + sql`${table.type} <> 'retained' or (${table.reason} is not null and length(trim(${table.reason})) > 0)`, + ), + lifecycleCheck: check( + 'booking_request_payment_resolutions_lifecycle_check', + sql`( + (${table.status} = 'pending' and ${table.type} = 'refund' + and ${table.idempotencyKey} is not null and ${table.operationFingerprint} is not null + and ${table.resolvedAt} is null and ${table.movementId} is null) + or + (${table.status} = 'failed' and ${table.type} = 'refund' + and ${table.idempotencyKey} is not null and ${table.operationFingerprint} is not null + and ${table.resolvedAt} is not null and ${table.movementId} is null) + or + (${table.status} = 'completed' and ${table.resolvedAt} is not null and ( + (${table.type} in ('refund', 'external_return') and ${table.movementId} is not null) + or + (${table.type} = 'retained' and ${table.movementId} is null) + )) + )`, ), })); diff --git a/packages/database/src/schema/folio.ts b/packages/database/src/schema/folio.ts index 9fd7d3a0..1b3b14c7 100644 --- a/packages/database/src/schema/folio.ts +++ b/packages/database/src/schema/folio.ts @@ -201,4 +201,8 @@ export const payments = pgTable('payments', { 'payments_booking_request_parent_positive_check', sql`${table.bookingRequestId} is null or ${table.originalPaymentId} is not null or ${table.amount} > 0`, ), + bookingRequestChildShapeCheck: check( + 'payments_booking_request_child_shape_check', + sql`${table.bookingRequestId} is null or ${table.originalPaymentId} is null or (${table.amount} < 0 and ${table.status} = 'captured')`, + ), })); From dd025802ef21daef4ccb4055adfbef413c12d5f2 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 23:11:53 +0200 Subject: [PATCH 24/87] fix(booking-requests): close payment correlation gaps --- .../booking-request-payment.db.spec.ts | 73 +++++++ .../booking-request-payment.service.ts | 27 +++ .../booking-request-payment.spec.ts | 185 ++++++++++++++++++ .../saved-payment-method-gateway.interface.ts | 4 + .../mock-saved-payment-method.gateway.spec.ts | 11 ++ .../mock-saved-payment-method.gateway.ts | 25 ++- .../modules/payment/stripe-financial-state.ts | 26 +++ ...tripe-saved-payment-method.gateway.spec.ts | 8 + .../stripe-saved-payment-method.gateway.ts | 5 + .../payment/stripe-webhook.controller.ts | 69 ++++++- .../modules/payment/stripe-webhook.spec.ts | 104 ++++++++++ .../booking-request-migration-safety.spec.ts | 24 +++ .../src/booking-request-schema.spec.ts | 26 +++ ...024_booking_request_financial_recovery.sql | 107 ++++++++++ packages/database/src/push-schema.ts | 84 ++++++++ .../database/src/schema/booking-request.ts | 50 +++++ packages/database/src/schema/folio.ts | 14 +- 17 files changed, 834 insertions(+), 8 deletions(-) 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 index 81008297..fdda6c02 100644 --- 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 @@ -5,6 +5,7 @@ import { auditLogs, bookingRequestConsequences, bookingRequestInstallments, + bookingRequestPaymentAllocations, bookingRequestPaymentResolutions, bookingRequests, payments, @@ -24,6 +25,9 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = const ratePlanId = '71000000-0000-4000-a000-000000000003'; const requestId = '71000000-0000-4000-a000-000000000004'; 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 otherPropertyId = '72000000-0000-4000-a000-000000000001'; const otherRoomTypeId = '72000000-0000-4000-a000-000000000002'; const otherRatePlanId = '72000000-0000-4000-a000-000000000003'; @@ -135,12 +139,16 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = 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(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(ratePlans).where(eq(ratePlans.id, ratePlanId)); await db.delete(roomTypes).where(eq(roomTypes.id, roomTypeId)); @@ -292,4 +300,69 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = 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', + }); + }); }); 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 index d2209891..939dc4ea 100644 --- a/apps/api/src/modules/booking-request/booking-request-payment.service.ts +++ b/apps/api/src/modules/booking-request/booking-request-payment.service.ts @@ -286,6 +286,9 @@ export class BookingRequestPaymentService { ) { 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, @@ -519,6 +522,9 @@ export class BookingRequestPaymentService { 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, @@ -836,6 +842,13 @@ export class BookingRequestPaymentService { 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); } @@ -1033,6 +1046,13 @@ export class BookingRequestPaymentService { 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); } @@ -1446,6 +1466,13 @@ export class BookingRequestPaymentService { ); 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); } 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 index a639cc86..8f539b39 100644 --- a/apps/api/src/modules/booking-request/booking-request-payment.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-payment.spec.ts @@ -493,6 +493,23 @@ describe('BookingRequestPaymentService installments', () => { 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({ @@ -599,6 +616,9 @@ describe('BookingRequestPaymentService saved-card charges', () => { 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:'), @@ -618,6 +638,30 @@ describe('BookingRequestPaymentService saved-card charges', () => { ]); }); + 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( @@ -1443,6 +1487,68 @@ describe('BookingRequestPaymentService external movements and denial resolutions ); }); + 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({ @@ -1520,6 +1626,45 @@ describe('BookingRequestPaymentService external movements and denial resolutions )).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' })], @@ -1640,6 +1785,46 @@ describe('BookingRequestPaymentService external movements and denial resolutions ); }); + 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({ 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 index fee43029..32ff86ed 100644 --- 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 @@ -14,6 +14,10 @@ export type SavedPaymentMethodProvenance = { 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; 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 index e4c6b283..3224d0c3 100644 --- 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 @@ -72,6 +72,9 @@ describe('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', @@ -86,6 +89,11 @@ describe('MockSavedPaymentMethodGateway', () => { requiresAction: false, }); expect(retry).toEqual(first); + + await expect(gateway.charge({ + ...input, + paymentId: 'dddddddd-0000-4000-a000-000000000001', + })).rejects.toThrow(/idempotency.*different.*payment|identity/i); }); }); @@ -184,6 +192,9 @@ describe('PaymentModule saved-payment-method registration', () => { 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', 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 index af5b4750..7520ed48 100644 --- a/apps/api/src/modules/payment/mock-saved-payment-method.gateway.ts +++ b/apps/api/src/modules/payment/mock-saved-payment-method.gateway.ts @@ -15,11 +15,18 @@ type MockSetupRecord = { 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(); + private readonly chargesByKey = new Map(); async createSetup( _email: string, @@ -74,14 +81,26 @@ export class MockSavedPaymentMethodGateway implements SavedPaymentMethodGateway async charge(input: SavedPaymentMethodChargeInput): Promise { const existing = this.chargesByKey.get(input.idempotencyKey); - if (existing) return existing; + 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); + this.chargesByKey.set(input.idempotencyKey, { + result, + paymentId: input.paymentId, + propertyId: input.propertyId, + bookingRequestId: input.bookingRequestId, + }); return result; } diff --git a/apps/api/src/modules/payment/stripe-financial-state.ts b/apps/api/src/modules/payment/stripe-financial-state.ts index a2c8424d..2e3e32df 100644 --- a/apps/api/src/modules/payment/stripe-financial-state.ts +++ b/apps/api/src/modules/payment/stripe-financial-state.ts @@ -6,6 +6,32 @@ export type PaymentIntentEvent = | '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' 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 index 790bc590..1556aae2 100644 --- 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 @@ -229,6 +229,9 @@ describe('StripeSavedPaymentMethodGateway', () => { 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', @@ -243,6 +246,11 @@ describe('StripeSavedPaymentMethodGateway', () => { 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', 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 index e7a50786..ad62653e 100644 --- a/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts +++ b/apps/api/src/modules/payment/stripe-saved-payment-method.gateway.ts @@ -122,6 +122,11 @@ export class StripeSavedPaymentMethodGateway implements SavedPaymentMethodGatewa 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', diff --git a/apps/api/src/modules/payment/stripe-webhook.controller.ts b/apps/api/src/modules/payment/stripe-webhook.controller.ts index 5f78bd84..e4692468 100644 --- a/apps/api/src/modules/payment/stripe-webhook.controller.ts +++ b/apps/api/src/modules/payment/stripe-webhook.controller.ts @@ -28,8 +28,10 @@ import { ensureBookingRequestFinancialConsequence } from '../booking-request/boo import { decidePaymentIntentTransition, decideRefundTransition, + paymentIntentCorrelation, refundCorrelation, type PaymentIntentEvent, + type PaymentIntentCorrelation, type PaymentIntentLedgerStatus, type RefundProviderStatus, } from './stripe-financial-state'; @@ -169,10 +171,16 @@ export class StripeWebhookController { } private async finalizePaymentIntent(pi: Stripe.PaymentIntent, event: PaymentIntentEvent) { - const initial = await this.findPaymentByGatewayTransactionId(pi.id); + let correlation: PaymentIntentCorrelation | undefined; + let initial = await this.findPaymentByGatewayTransactionId(pi.id); if (!initial) { - this.logger.warn(`No payment found for PaymentIntent ${pi.id}`); - return; + correlation = paymentIntentCorrelation(pi.metadata); + initial = await this.findPaymentByCorrelation(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) => { @@ -197,10 +205,46 @@ export class StripeWebhookController { .from(payments) .where(and(eq(payments.id, initial.id), eq(payments.propertyId, initial.propertyId))) .for('update'); - const payment = lockedRows.find((row: typeof payments.$inferSelect) => + 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 (payment.status !== 'pending') { + throw new ConflictException( + `Stripe PaymentIntent metadata can bind only a pending payment, not '${payment.status}'`, + ); + } + if (payment.gatewayTransactionId && payment.gatewayTransactionId !== pi.id) { + throw new ConflictException( + 'Stripe PaymentIntent does not match the provider identity already bound to the payment', + ); + } + 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'); + } + payment = bound; + } + } + if (event === 'succeeded' && request?.status === 'denied' && payment.status !== 'captured') { await this.auditUnexpectedProviderState(tx, payment, { stripeObjectId: pi.id, @@ -707,4 +751,21 @@ export class StripeWebhookController { } 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/payment/stripe-webhook.spec.ts b/apps/api/src/modules/payment/stripe-webhook.spec.ts index 1d8b9420..bd597ab5 100644 --- a/apps/api/src/modules/payment/stripe-webhook.spec.ts +++ b/apps/api/src/modules/payment/stripe-webhook.spec.ts @@ -1,5 +1,6 @@ import { Test } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; +import { createHash } from 'node:crypto'; import { auditLogs, bookingRequestConsequences, @@ -11,6 +12,7 @@ import { DRIZZLE } from '../../database/database.module'; import { FolioService } from '../folio/folio.service'; import { WebhookService } from '../webhook/webhook.service'; import { reconcileBookingRequestPaymentAllocations } from '../booking-request/booking-request-allocation-reconciler'; +import { BookingRequestPaymentService } from '../booking-request/booking-request-payment.service'; import { StripeWebhookController } from './stripe-webhook.controller'; vi.mock('../booking-request/booking-request-allocation-reconciler', () => ({ @@ -280,6 +282,108 @@ describe('StripeWebhookController financial finalization', () => { expect(h.webhookService.emit).not.toHaveBeenCalled(); }); + it('binds and finalizes an unknown PaymentIntent from exact signed metadata', async () => { + const clientKey = 'api-crashed-before-provider-id-commit'; + const idempotencyKey = `booking-request-charge:${createHash('sha256') + .update(`${PROPERTY_ID}:${clientKey}`) + .digest('hex')}`; + const h = await harness({ + requests: [request({ + currencyCode: 'USD', + submittedQuoteSnapshot: { grandTotal: '100.00' }, + stripeCustomerId: 'cus_saved', + stripePaymentMethodId: 'pm_saved', + })], + payments: [payment({ + gatewayTransactionId: null, + idempotencyKey, + amount: '25.00', + })], + }); + const pi = { + id: 'pi_recovered_from_metadata', + metadata: { + haip_payment_id: PAYMENT_ID, + haip_property_id: PROPERTY_ID, + haip_booking_request_id: REQUEST_ID, + }, + }; + + await h.controller.handlePaymentIntentSucceeded(pi); + await h.controller.handlePaymentIntentSucceeded(pi); + + expect(h.state.payments[0]).toMatchObject({ + status: 'captured', + gatewayTransactionId: 'pi_recovered_from_metadata', + }); + expect(h.state.consequences).toHaveLength(1); + + const gateway = { charge: vi.fn() }; + const service = new (BookingRequestPaymentService as any)( + h.db, + gateway, + h.folioService, + { refund: vi.fn() }, + ) as BookingRequestPaymentService; + const replay = await service.chargeSavedCard( + REQUEST_ID, + PROPERTY_ID, + { amount: '25.00', idempotencyKey: clientKey }, + ); + expect(replay).toMatchObject({ + id: PAYMENT_ID, + status: 'captured', + }); + expect(replay).not.toHaveProperty('gatewayTransactionId'); + expect(gateway.charge).not.toHaveBeenCalled(); + }); + + it.each([ + ['missing', undefined], + ['cross-property', { + haip_payment_id: PAYMENT_ID, + haip_property_id: 'aaaaaaaa-0000-4000-a000-000000000099', + haip_booking_request_id: REQUEST_ID, + }], + ['cross-request', { + haip_payment_id: PAYMENT_ID, + haip_property_id: PROPERTY_ID, + haip_booking_request_id: 'bbbbbbbb-0000-4000-a000-000000000099', + }], + ['spoofed-payment', { + haip_payment_id: 'cccccccc-0000-4000-a000-000000000099', + haip_property_id: PROPERTY_ID, + haip_booking_request_id: REQUEST_ID, + }], + ] as const)('rejects %s metadata for an unknown PaymentIntent', async (_label, metadata) => { + const h = await harness({ payments: [payment({ gatewayTransactionId: null })] }); + + await expect(h.controller.handlePaymentIntentSucceeded({ + id: 'pi_unknown_spoofed', metadata, + })).rejects.toThrow(/metadata|identify|correlation/i); + expect(h.state.payments[0]).toMatchObject({ + status: 'pending', + gatewayTransactionId: null, + }); + }); + + it.each([ + ['terminal state', { status: 'failed', gatewayTransactionId: null }], + ['different provider identity', { status: 'pending', gatewayTransactionId: 'pi_other' }], + ])('rejects metadata binding against a %s', async (_label, overrides) => { + const h = await harness({ payments: [payment(overrides)] }); + + await expect(h.controller.handlePaymentIntentSucceeded({ + id: 'pi_unknown_conflict', + metadata: { + haip_payment_id: PAYMENT_ID, + haip_property_id: PROPERTY_ID, + haip_booking_request_id: REQUEST_ID, + }, + })).rejects.toThrow(/pending payment|already bound|provider identity/i); + expect(h.state.payments[0]!.status).toBe(overrides.status); + }); + it('repairs a captured replay without duplicating its durable consequence', async () => { const h = await harness({ requests: [request({ status: 'accepted', acceptedFolioId: FOLIO_ID })], diff --git a/packages/database/src/booking-request-migration-safety.spec.ts b/packages/database/src/booking-request-migration-safety.spec.ts index fa91ab47..fc847575 100644 --- a/packages/database/src/booking-request-migration-safety.spec.ts +++ b/packages/database/src/booking-request-migration-safety.spec.ts @@ -10,6 +10,10 @@ const paymentIntegrityMigration = readFileSync( new URL('./migrations/0023_booking_request_payment_integrity.sql', import.meta.url), 'utf8', ); +const financialRecoveryMigration = readFileSync( + new URL('./migrations/0024_booking_request_financial_recovery.sql', import.meta.url), + 'utf8', +); describe('booking request accepted-pricing migration safety', () => { it('fails instead of accepting an already-accepted request without an operational snapshot', () => { @@ -61,4 +65,24 @@ describe('booking request payment integrity migration safety', () => { expect(source).toContain('payments_booking_request_parent_positive_check'); } }); + + it('stages complete aggregate ownership, including a movement tied to its exact parent', () => { + for (const source of [financialRecoveryMigration, pushSchema]) { + expect(source).toContain('payments_property_request_parent_id_unique'); + expect(source).toContain('booking_request_consequences_request_fkey'); + expect(source).toContain('booking_request_payment_resolutions_parent_movement_fkey'); + expect(source).toMatch( + /FOREIGN KEY \(property_id, booking_request_id, payment_id, movement_id\)[\s\S]*REFERENCES payments\(property_id, booking_request_id, original_payment_id, id\)/, + ); + } + }); + + it('repairs net allocations and derived installment state in both migration paths', () => { + for (const source of [financialRecoveryMigration, pushSchema]) { + expect(source).toContain('booking_request_net_allocation_repair'); + expect(source).toMatch(/DELETE FROM booking_request_payment_allocations/i); + expect(source).toMatch(/UPDATE booking_request_payment_allocations/i); + expect(source).toMatch(/UPDATE booking_request_installments/i); + } + }); }); diff --git a/packages/database/src/booking-request-schema.spec.ts b/packages/database/src/booking-request-schema.spec.ts index b31314b3..2343c7f3 100644 --- a/packages/database/src/booking-request-schema.spec.ts +++ b/packages/database/src/booking-request-schema.spec.ts @@ -55,6 +55,8 @@ describe('booking request schema', () => { expect(consequenceIndexNames).toContain( 'booking_request_consequences_property_request_kind_unique', ); + expect(getTableConfig(bookingRequestConsequences).foreignKeys.map((key) => key.getName())) + .toContain('booking_request_consequences_request_fkey'); const installmentChecks = getTableConfig(bookingRequestInstallments) .checks.map((check) => check.name); @@ -66,6 +68,12 @@ describe('booking request schema', () => { const allocationChecks = getTableConfig(bookingRequestPaymentAllocations) .checks.map((check) => check.name); expect(allocationChecks).toContain('booking_request_payment_allocations_positive_check'); + expect(getTableConfig(bookingRequestPaymentAllocations).foreignKeys.map((key) => key.getName())) + .toEqual(expect.arrayContaining([ + 'booking_request_payment_allocations_request_fkey', + 'booking_request_payment_allocations_payment_fkey', + 'booking_request_payment_allocations_installment_fkey', + ])); const resolutionConfig = getTableConfig(bookingRequestPaymentResolutions); expect(resolutionConfig.checks.map((check) => check.name)).toEqual(expect.arrayContaining([ 'booking_request_payment_resolutions_positive_check', @@ -76,12 +84,30 @@ describe('booking request schema', () => { expect(resolutionConfig.indexes.map((index) => index.config.name)).toContain( 'booking_request_payment_resolutions_property_idempotency_unique', ); + expect(resolutionConfig.foreignKeys.map((key) => key.getName())).toEqual( + expect.arrayContaining([ + 'booking_request_payment_resolutions_request_fkey', + 'booking_request_payment_resolutions_payment_fkey', + 'booking_request_payment_resolutions_parent_movement_fkey', + ]), + ); + expect(getTableConfig(bookingRequestInstallments).foreignKeys.map((key) => key.getName())) + .toContain('booking_request_installments_request_fkey'); expect(getTableConfig(payments).checks.map((check) => check.name)).toEqual( expect.arrayContaining([ 'payments_booking_request_parent_positive_check', 'payments_booking_request_child_shape_check', ]), ); + expect(getTableConfig(payments).indexes.map((index) => index.config.name)).toContain( + 'payments_property_request_parent_id_unique', + ); + expect(getTableConfig(payments).foreignKeys.map((key) => key.getName())).toEqual( + expect.arrayContaining([ + 'payments_booking_request_fkey', + 'payments_booking_request_parent_fkey', + ]), + ); const deliveryIndexNames = getTableConfig(webhookDeliveries) .indexes.map((index) => index.config.name); diff --git a/packages/database/src/migrations/0024_booking_request_financial_recovery.sql b/packages/database/src/migrations/0024_booking_request_financial_recovery.sql index a2a8d1be..0101a1b5 100644 --- a/packages/database/src/migrations/0024_booking_request_financial_recovery.sql +++ b/packages/database/src/migrations/0024_booking_request_financial_recovery.sql @@ -54,12 +54,112 @@ $booking_request_resolution_provenance$; CREATE UNIQUE INDEX IF NOT EXISTS br_payment_resolutions_property_provider_tx_unique ON booking_request_payment_resolutions (property_id, provider_transaction_id); +CREATE UNIQUE INDEX IF NOT EXISTS payments_property_request_parent_id_unique + ON payments (property_id, booking_request_id, original_payment_id, id); + +-- booking_request_net_allocation_repair: releases allocations made stale by +-- completed refund/return movements from the pre-reconciliation release. Rows +-- are consumed deterministically by allocation creation order. +WITH net_capacity AS ( + SELECT parent.property_id, + parent.booking_request_id, + parent.id AS payment_id, + GREATEST(parent.amount + COALESCE(SUM(child.amount) + FILTER (WHERE child.status = 'captured'), 0), 0) AS net_amount + FROM payments parent + LEFT JOIN payments child + ON child.property_id = parent.property_id + AND child.booking_request_id = parent.booking_request_id + AND child.original_payment_id = parent.id + WHERE parent.booking_request_id IS NOT NULL + AND parent.original_payment_id IS NULL + GROUP BY parent.property_id, parent.booking_request_id, parent.id, parent.amount +), ranked AS ( + SELECT allocation.id, + capacity.net_amount, + COALESCE(SUM(allocation.amount) OVER ( + PARTITION BY allocation.property_id, allocation.booking_request_id, allocation.payment_id + ORDER BY allocation.created_at, allocation.id + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ), 0) AS used_before + FROM booking_request_payment_allocations allocation + JOIN net_capacity capacity + ON capacity.property_id = allocation.property_id + AND capacity.booking_request_id = allocation.booking_request_id + AND capacity.payment_id = allocation.payment_id +) +DELETE FROM booking_request_payment_allocations allocation +USING ranked +WHERE allocation.id = ranked.id + AND ranked.used_before >= ranked.net_amount; + +WITH net_capacity AS ( + SELECT parent.property_id, + parent.booking_request_id, + parent.id AS payment_id, + GREATEST(parent.amount + COALESCE(SUM(child.amount) + FILTER (WHERE child.status = 'captured'), 0), 0) AS net_amount + FROM payments parent + LEFT JOIN payments child + ON child.property_id = parent.property_id + AND child.booking_request_id = parent.booking_request_id + AND child.original_payment_id = parent.id + WHERE parent.booking_request_id IS NOT NULL + AND parent.original_payment_id IS NULL + GROUP BY parent.property_id, parent.booking_request_id, parent.id, parent.amount +), ranked AS ( + SELECT allocation.id, + allocation.amount, + capacity.net_amount, + COALESCE(SUM(allocation.amount) OVER ( + PARTITION BY allocation.property_id, allocation.booking_request_id, allocation.payment_id + ORDER BY allocation.created_at, allocation.id + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ), 0) AS used_before + FROM booking_request_payment_allocations allocation + JOIN net_capacity capacity + ON capacity.property_id = allocation.property_id + AND capacity.booking_request_id = allocation.booking_request_id + AND capacity.payment_id = allocation.payment_id +) +UPDATE booking_request_payment_allocations allocation +SET amount = LEAST(ranked.amount, ranked.net_amount - ranked.used_before) +FROM ranked +WHERE allocation.id = ranked.id + AND ranked.amount > ranked.net_amount - ranked.used_before; + +WITH installment_totals AS ( + SELECT installment.id, + installment.resolved_amount, + COALESCE(SUM(allocation.amount), 0) AS amount + FROM booking_request_installments installment + LEFT JOIN booking_request_payment_allocations allocation + ON allocation.property_id = installment.property_id + AND allocation.booking_request_id = installment.booking_request_id + AND allocation.installment_id = installment.id + GROUP BY installment.id, installment.resolved_amount +) +UPDATE booking_request_installments installment +SET allocated_amount = LEAST(total.amount, total.resolved_amount), + status = CASE + WHEN total.amount <= 0 THEN 'unpaid' + WHEN total.amount >= total.resolved_amount THEN 'paid' + ELSE 'partial' + END::booking_request_installment_status, + updated_at = now() +FROM installment_totals total +WHERE installment.id = total.id; ALTER TABLE booking_request_payment_resolutions DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_retained_reason_check, DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_lifecycle_check; DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_consequences_request_fkey') THEN + ALTER TABLE booking_request_consequences ADD CONSTRAINT booking_request_consequences_request_fkey + FOREIGN KEY (property_id, booking_request_id) + REFERENCES booking_requests(property_id, id) NOT VALID; + END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_retained_reason_check') THEN ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_retained_reason_check CHECK (type <> 'retained' OR (reason IS NOT NULL AND NULLIF(BTRIM(reason), '') IS NOT NULL)) NOT VALID; @@ -104,11 +204,18 @@ DO $$ BEGIN FOREIGN KEY (property_id, booking_request_id, original_payment_id) REFERENCES payments(property_id, booking_request_id, id) NOT VALID; END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_parent_movement_fkey') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_parent_movement_fkey + FOREIGN KEY (property_id, booking_request_id, payment_id, movement_id) + REFERENCES payments(property_id, booking_request_id, original_payment_id, id) NOT VALID; + END IF; END $$; ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_retained_reason_check; ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_lifecycle_check; ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_child_shape_check; ALTER TABLE booking_request_installments VALIDATE CONSTRAINT booking_request_installments_request_fkey; +ALTER TABLE booking_request_consequences VALIDATE CONSTRAINT booking_request_consequences_request_fkey; ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_fkey; ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_parent_fkey; +ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_parent_movement_fkey; diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index 9fc416a2..8ff8e095 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -1625,6 +1625,7 @@ async function main() { `CREATE UNIQUE INDEX IF NOT EXISTS booking_requests_property_id_unique ON booking_requests (property_id, id)`, `CREATE UNIQUE INDEX IF NOT EXISTS booking_request_installments_property_request_id_unique ON booking_request_installments (property_id, booking_request_id, id)`, `CREATE UNIQUE INDEX IF NOT EXISTS payments_property_request_id_unique ON payments (property_id, booking_request_id, id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS payments_property_request_parent_id_unique ON payments (property_id, booking_request_id, original_payment_id, id)`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_amount_kind_check') THEN ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_amount_kind_check @@ -1739,6 +1740,79 @@ async function main() { $booking_request_resolution_provenance$`, `CREATE UNIQUE INDEX IF NOT EXISTS br_payment_resolutions_property_provider_tx_unique ON booking_request_payment_resolutions (property_id, provider_transaction_id)`, + `-- booking_request_net_allocation_repair + WITH net_capacity AS ( + SELECT parent.property_id, parent.booking_request_id, parent.id AS payment_id, + GREATEST(parent.amount + COALESCE(SUM(child.amount) FILTER (WHERE child.status = 'captured'), 0), 0) AS net_amount + FROM payments parent + LEFT JOIN payments child + ON child.property_id = parent.property_id + AND child.booking_request_id = parent.booking_request_id + AND child.original_payment_id = parent.id + WHERE parent.booking_request_id IS NOT NULL AND parent.original_payment_id IS NULL + GROUP BY parent.property_id, parent.booking_request_id, parent.id, parent.amount + ), ranked AS ( + SELECT allocation.id, capacity.net_amount, + COALESCE(SUM(allocation.amount) OVER ( + PARTITION BY allocation.property_id, allocation.booking_request_id, allocation.payment_id + ORDER BY allocation.created_at, allocation.id + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ), 0) AS used_before + FROM booking_request_payment_allocations allocation + JOIN net_capacity capacity + ON capacity.property_id = allocation.property_id + AND capacity.booking_request_id = allocation.booking_request_id + AND capacity.payment_id = allocation.payment_id + ) + DELETE FROM booking_request_payment_allocations allocation + USING ranked + WHERE allocation.id = ranked.id AND ranked.used_before >= ranked.net_amount`, + `WITH net_capacity AS ( + SELECT parent.property_id, parent.booking_request_id, parent.id AS payment_id, + GREATEST(parent.amount + COALESCE(SUM(child.amount) FILTER (WHERE child.status = 'captured'), 0), 0) AS net_amount + FROM payments parent + LEFT JOIN payments child + ON child.property_id = parent.property_id + AND child.booking_request_id = parent.booking_request_id + AND child.original_payment_id = parent.id + WHERE parent.booking_request_id IS NOT NULL AND parent.original_payment_id IS NULL + GROUP BY parent.property_id, parent.booking_request_id, parent.id, parent.amount + ), ranked AS ( + SELECT allocation.id, allocation.amount, capacity.net_amount, + COALESCE(SUM(allocation.amount) OVER ( + PARTITION BY allocation.property_id, allocation.booking_request_id, allocation.payment_id + ORDER BY allocation.created_at, allocation.id + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ), 0) AS used_before + FROM booking_request_payment_allocations allocation + JOIN net_capacity capacity + ON capacity.property_id = allocation.property_id + AND capacity.booking_request_id = allocation.booking_request_id + AND capacity.payment_id = allocation.payment_id + ) + UPDATE booking_request_payment_allocations allocation + SET amount = LEAST(ranked.amount, ranked.net_amount - ranked.used_before) + FROM ranked + WHERE allocation.id = ranked.id AND ranked.amount > ranked.net_amount - ranked.used_before`, + `WITH installment_totals AS ( + SELECT installment.id, installment.resolved_amount, COALESCE(SUM(allocation.amount), 0) AS amount + FROM booking_request_installments installment + LEFT JOIN booking_request_payment_allocations allocation + ON allocation.property_id = installment.property_id + AND allocation.booking_request_id = installment.booking_request_id + AND allocation.installment_id = installment.id + GROUP BY installment.id, installment.resolved_amount + ) + UPDATE booking_request_installments installment + SET allocated_amount = LEAST(total.amount, total.resolved_amount), + status = CASE + WHEN total.amount <= 0 THEN 'unpaid' + WHEN total.amount >= total.resolved_amount THEN 'paid' + ELSE 'partial' + END::booking_request_installment_status, + updated_at = now() + FROM installment_totals total + WHERE installment.id = total.id`, `ALTER TABLE booking_request_payment_resolutions DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_retained_reason_check, DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_lifecycle_check`, @@ -1768,6 +1842,10 @@ async function main() { END IF; END $$`, `DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_consequences_request_fkey') THEN + ALTER TABLE booking_request_consequences ADD CONSTRAINT booking_request_consequences_request_fkey + FOREIGN KEY (property_id, booking_request_id) REFERENCES booking_requests(property_id, id) NOT VALID; + END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_request_fkey') THEN ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_request_fkey FOREIGN KEY (property_id, booking_request_id) REFERENCES booking_requests(property_id, id) NOT VALID; @@ -1780,13 +1858,19 @@ async function main() { ALTER TABLE payments ADD CONSTRAINT payments_booking_request_parent_fkey FOREIGN KEY (property_id, booking_request_id, original_payment_id) REFERENCES payments(property_id, booking_request_id, id) NOT VALID; END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_payment_resolutions_parent_movement_fkey') THEN + ALTER TABLE booking_request_payment_resolutions ADD CONSTRAINT booking_request_payment_resolutions_parent_movement_fkey + FOREIGN KEY (property_id, booking_request_id, payment_id, movement_id) REFERENCES payments(property_id, booking_request_id, original_payment_id, id) NOT VALID; + END IF; END $$`, `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_retained_reason_check`, `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_lifecycle_check`, `ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_child_shape_check`, `ALTER TABLE booking_request_installments VALIDATE CONSTRAINT booking_request_installments_request_fkey`, + `ALTER TABLE booking_request_consequences VALIDATE CONSTRAINT booking_request_consequences_request_fkey`, `ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_fkey`, `ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_parent_fkey`, + `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_parent_movement_fkey`, `DO $booking_request_accepted_snapshot_precondition$ BEGIN IF EXISTS ( diff --git a/packages/database/src/schema/booking-request.ts b/packages/database/src/schema/booking-request.ts index 115c9d43..8176aba0 100644 --- a/packages/database/src/schema/booking-request.ts +++ b/packages/database/src/schema/booking-request.ts @@ -1,6 +1,7 @@ import { check, date, + foreignKey, integer, jsonb, numeric, @@ -159,6 +160,11 @@ export const bookingRequestConsequences = pgTable('booking_request_consequences' propertyRequestKindUnique: uniqueIndex('booking_request_consequences_property_request_kind_unique') .on(table.propertyId, table.bookingRequestId, table.kind), + requestOwnership: foreignKey({ + name: 'booking_request_consequences_request_fkey', + columns: [table.propertyId, table.bookingRequestId], + foreignColumns: [bookingRequests.propertyId, bookingRequests.id], + }), })); export const bookingRequestInstallments = pgTable('booking_request_installments', { @@ -179,6 +185,11 @@ export const bookingRequestInstallments = pgTable('booking_request_installments' }, (table) => ({ propertyRequestIdUnique: uniqueIndex('booking_request_installments_property_request_id_unique') .on(table.propertyId, table.bookingRequestId, table.id), + requestOwnership: foreignKey({ + name: 'booking_request_installments_request_fkey', + columns: [table.propertyId, table.bookingRequestId], + foreignColumns: [bookingRequests.propertyId, bookingRequests.id], + }), amountKindCheck: check( 'booking_request_installments_amount_kind_check', sql`( @@ -212,6 +223,25 @@ export const bookingRequestPaymentAllocations = pgTable('booking_request_payment }, (table) => ({ paymentInstallmentUnique: uniqueIndex('booking_request_payment_allocations_payment_installment_unique') .on(table.paymentId, table.installmentId), + requestOwnership: foreignKey({ + name: 'booking_request_payment_allocations_request_fkey', + columns: [table.propertyId, table.bookingRequestId], + foreignColumns: [bookingRequests.propertyId, bookingRequests.id], + }), + paymentOwnership: foreignKey({ + name: 'booking_request_payment_allocations_payment_fkey', + columns: [table.propertyId, table.bookingRequestId, table.paymentId], + foreignColumns: [payments.propertyId, payments.bookingRequestId, payments.id], + }), + installmentOwnership: foreignKey({ + name: 'booking_request_payment_allocations_installment_fkey', + columns: [table.propertyId, table.bookingRequestId, table.installmentId], + foreignColumns: [ + bookingRequestInstallments.propertyId, + bookingRequestInstallments.bookingRequestId, + bookingRequestInstallments.id, + ], + }), positiveCheck: check( 'booking_request_payment_allocations_positive_check', sql`${table.amount} > 0`, @@ -245,6 +275,26 @@ export const bookingRequestPaymentResolutions = pgTable('booking_request_payment propertyProviderTransactionUnique: uniqueIndex('br_payment_resolutions_property_provider_tx_unique') .on(table.propertyId, table.providerTransactionId), + requestOwnership: foreignKey({ + name: 'booking_request_payment_resolutions_request_fkey', + columns: [table.propertyId, table.bookingRequestId], + foreignColumns: [bookingRequests.propertyId, bookingRequests.id], + }), + paymentOwnership: foreignKey({ + name: 'booking_request_payment_resolutions_payment_fkey', + columns: [table.propertyId, table.bookingRequestId, table.paymentId], + foreignColumns: [payments.propertyId, payments.bookingRequestId, payments.id], + }), + parentMovementOwnership: foreignKey({ + name: 'booking_request_payment_resolutions_parent_movement_fkey', + columns: [table.propertyId, table.bookingRequestId, table.paymentId, table.movementId], + foreignColumns: [ + payments.propertyId, + payments.bookingRequestId, + payments.originalPaymentId, + payments.id, + ], + }), positiveCheck: check( 'booking_request_payment_resolutions_positive_check', sql`${table.amount} > 0`, diff --git a/packages/database/src/schema/folio.ts b/packages/database/src/schema/folio.ts index 1b3b14c7..da7f3517 100644 --- a/packages/database/src/schema/folio.ts +++ b/packages/database/src/schema/folio.ts @@ -1,5 +1,5 @@ import { sql } from 'drizzle-orm'; -import { check, pgTable, uuid, varchar, text, boolean, timestamp, numeric, pgEnum, uniqueIndex } from 'drizzle-orm/pg-core'; +import { check, foreignKey, pgTable, uuid, varchar, text, boolean, timestamp, numeric, pgEnum, uniqueIndex } from 'drizzle-orm/pg-core'; import { properties } from './property.js'; import { reservations, bookings } from './reservation.js'; import { guests } from './guest.js'; @@ -197,6 +197,18 @@ export const payments = pgTable('payments', { .on(table.propertyId, table.idempotencyKey), propertyRequestIdUnique: uniqueIndex('payments_property_request_id_unique') .on(table.propertyId, table.bookingRequestId, table.id), + propertyRequestParentIdUnique: uniqueIndex('payments_property_request_parent_id_unique') + .on(table.propertyId, table.bookingRequestId, table.originalPaymentId, table.id), + bookingRequestOwnership: foreignKey({ + name: 'payments_booking_request_fkey', + columns: [table.propertyId, table.bookingRequestId], + foreignColumns: [bookingRequests.propertyId, bookingRequests.id], + }), + bookingRequestParentOwnership: foreignKey({ + name: 'payments_booking_request_parent_fkey', + columns: [table.propertyId, table.bookingRequestId, table.originalPaymentId], + foreignColumns: [table.propertyId, table.bookingRequestId, table.id], + }), bookingRequestParentPositiveCheck: check( 'payments_booking_request_parent_positive_check', sql`${table.bookingRequestId} is null or ${table.originalPaymentId} is not null or ${table.amount} > 0`, From ec4ce69d6c67f29fec3db9bccb96e0f646c65b87 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 23:30:52 +0200 Subject: [PATCH 25/87] fix(booking-requests): harden payment recovery identity --- .../booking-request-payment.db.spec.ts | 194 ++++++++++++++++++ .../modules/payment/stripe-financial-state.ts | 11 +- .../payment/stripe-webhook.controller.ts | 63 +++++- .../modules/payment/stripe-webhook.spec.ts | 75 ++++++- .../booking-request-migration-safety.spec.ts | 5 + ...024_booking_request_financial_recovery.sql | 136 +++++++----- packages/database/src/push-schema.ts | 113 ++++++---- 7 files changed, 498 insertions(+), 99 deletions(-) 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 index fdda6c02..e0722f4e 100644 --- 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 @@ -1,6 +1,7 @@ import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import { eq } from 'drizzle-orm'; +import { readFileSync } from 'node:fs'; import { auditLogs, bookingRequestConsequences, @@ -18,6 +19,10 @@ 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/database/src/migrations/0024_booking_request_financial_recovery.sql', import.meta.url), + 'utf8', +); describeDatabase('Booking Request payment PostgreSQL concurrency contract', () => { const propertyId = '71000000-0000-4000-a000-000000000001'; @@ -28,6 +33,17 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = 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'; @@ -365,4 +381,182 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = 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', + }), + }), + ]); + + 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, unchangedAllocationId))).toHaveLength(0); + }); }); diff --git a/apps/api/src/modules/payment/stripe-financial-state.ts b/apps/api/src/modules/payment/stripe-financial-state.ts index 2e3e32df..39926a96 100644 --- a/apps/api/src/modules/payment/stripe-financial-state.ts +++ b/apps/api/src/modules/payment/stripe-financial-state.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException } from '@nestjs/common'; export type PaymentIntentEvent = + | 'processing' | 'succeeded' | 'payment_failed' | 'canceled' @@ -46,7 +47,10 @@ type PaymentDecision = { status: 'captured' | 'failed' | 'voided' | PaymentIntentLedgerStatus; }; -const targetStatus: Record = { +const targetStatus: Record< + Exclude, + 'captured' | 'failed' | 'voided' +> = { succeeded: 'captured', payment_failed: 'failed', canceled: 'voided', @@ -59,6 +63,11 @@ export function decidePaymentIntentTransition( 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( diff --git a/apps/api/src/modules/payment/stripe-webhook.controller.ts b/apps/api/src/modules/payment/stripe-webhook.controller.ts index e4692468..c55e0078 100644 --- a/apps/api/src/modules/payment/stripe-webhook.controller.ts +++ b/apps/api/src/modules/payment/stripe-webhook.controller.ts @@ -43,6 +43,7 @@ import { * Uses raw body for signature verification (Stripe requirement). * * Events handled: + * - payment_intent.processing → durable pending provider identity * - payment_intent.succeeded → captured * - payment_intent.payment_failed → failed * - payment_intent.canceled → voided @@ -119,6 +120,10 @@ export class StripeWebhookController { await this.handlePaymentIntentSucceeded(event.data.object as Stripe.PaymentIntent); break; + case 'payment_intent.processing': + await this.handlePaymentIntentProcessing(event.data.object as Stripe.PaymentIntent); + break; + case 'payment_intent.payment_failed': await this.handlePaymentIntentFailed(event.data.object as Stripe.PaymentIntent); break; @@ -158,6 +163,10 @@ export class StripeWebhookController { await this.finalizePaymentIntent(pi, 'succeeded'); } + private async handlePaymentIntentProcessing(pi: Stripe.PaymentIntent) { + await this.finalizePaymentIntent(pi, 'processing'); + } + private async handlePaymentIntentFailed(pi: Stripe.PaymentIntent) { await this.finalizePaymentIntent(pi, 'payment_failed'); } @@ -226,6 +235,7 @@ export class StripeWebhookController { 'Stripe PaymentIntent does not match the provider identity already bound to the payment', ); } + this.assertPaymentIntentBindingIdentity(pi, event, payment, request); if (!payment.gatewayTransactionId) { const boundRows = await tx .update(payments) @@ -316,7 +326,7 @@ export class StripeWebhookController { ?? { ...payment, folioId }; } - if (request && decision.action !== 'unexpected') { + if (request && decision.action !== 'unexpected' && current.status !== 'pending') { const financialEvent = current.status === 'captured' ? 'payment.received' as const : 'payment.failed' as const; @@ -334,7 +344,10 @@ export class StripeWebhookController { }, }); } - if (folioId && (current.status === 'captured' || decision.action === 'repair')) { + if (folioId && ( + current.status === 'captured' + || (decision.action === 'repair' && current.status !== 'pending') + )) { await this.folioService.recalculateBalance(folioId, payment.propertyId, tx); } return { @@ -707,6 +720,52 @@ export class StripeWebhookController { }); } + 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; diff --git a/apps/api/src/modules/payment/stripe-webhook.spec.ts b/apps/api/src/modules/payment/stripe-webhook.spec.ts index bd597ab5..3188fc80 100644 --- a/apps/api/src/modules/payment/stripe-webhook.spec.ts +++ b/apps/api/src/modules/payment/stripe-webhook.spec.ts @@ -54,6 +54,7 @@ function payment(overrides: Record = {}) { currencyCode: 'USD', gatewayProvider: 'stripe', gatewayTransactionId: 'pi_request_1', + gatewayPaymentToken: 'pm_saved', originalPaymentId: null, createdAt: new Date(), updatedAt: new Date(), @@ -61,6 +62,23 @@ function payment(overrides: Record = {}) { }; } +function unknownPaymentIntent(overrides: Record = {}) { + return { + id: 'pi_recovered_from_metadata', + amount: 2500, + amount_received: 2500, + currency: 'usd', + customer: 'cus_saved', + payment_method: 'pm_saved', + metadata: { + haip_payment_id: PAYMENT_ID, + haip_property_id: PROPERTY_ID, + haip_booking_request_id: REQUEST_ID, + }, + ...overrides, + }; +} + function resolution(id: string, overrides: Record = {}) { return { id, @@ -300,14 +318,7 @@ describe('StripeWebhookController financial finalization', () => { amount: '25.00', })], }); - const pi = { - id: 'pi_recovered_from_metadata', - metadata: { - haip_payment_id: PAYMENT_ID, - haip_property_id: PROPERTY_ID, - haip_booking_request_id: REQUEST_ID, - }, - }; + const pi = unknownPaymentIntent(); await h.controller.handlePaymentIntentSucceeded(pi); await h.controller.handlePaymentIntentSucceeded(pi); @@ -338,6 +349,54 @@ describe('StripeWebhookController financial finalization', () => { expect(gateway.charge).not.toHaveBeenCalled(); }); + it.each([ + ['configured amount', { amount: 2600 }], + ['received amount', { amount_received: 2400 }], + ['currency', { currency: 'eur' }], + ['customer', { customer: 'cus_copied_metadata' }], + ['payment method', { payment_method: 'pm_copied_metadata' }], + ])('rejects copied metadata with the wrong %s before binding', async (_label, overrides) => { + const h = await harness({ + requests: [request({ + currencyCode: 'USD', + stripeCustomerId: 'cus_saved', + stripePaymentMethodId: 'pm_saved', + })], + payments: [payment({ gatewayTransactionId: null, amount: '25.00' })], + }); + + await expect(h.controller.handlePaymentIntentSucceeded( + unknownPaymentIntent(overrides), + )).rejects.toThrow(/amount|currency|customer|payment method|identity/i); + expect(h.state.payments[0]).toMatchObject({ + status: 'pending', + gatewayTransactionId: null, + }); + expect(h.state.consequences).toHaveLength(0); + }); + + it('binds a processing PaymentIntent using configured amount while received amount is zero', async () => { + const h = await harness({ + requests: [request({ + currencyCode: 'USD', + stripeCustomerId: 'cus_saved', + stripePaymentMethodId: 'pm_saved', + })], + payments: [payment({ gatewayTransactionId: null, amount: '25.00' })], + }); + + await h.controller.handlePaymentIntentProcessing(unknownPaymentIntent({ + id: 'pi_processing_from_metadata', + amount_received: 0, + })); + + expect(h.state.payments[0]).toMatchObject({ + status: 'pending', + gatewayTransactionId: 'pi_processing_from_metadata', + }); + expect(h.state.consequences).toHaveLength(0); + }); + it.each([ ['missing', undefined], ['cross-property', { diff --git a/packages/database/src/booking-request-migration-safety.spec.ts b/packages/database/src/booking-request-migration-safety.spec.ts index fc847575..c78c91e9 100644 --- a/packages/database/src/booking-request-migration-safety.spec.ts +++ b/packages/database/src/booking-request-migration-safety.spec.ts @@ -80,9 +80,14 @@ describe('booking request payment integrity migration safety', () => { it('repairs net allocations and derived installment state in both migration paths', () => { for (const source of [financialRecoveryMigration, pushSchema]) { expect(source).toContain('booking_request_net_allocation_repair'); + expect(source).toContain('task7-net-allocation-v1:'); + expect(source).toContain('audit_logs_booking_request_allocation_repair_unique'); + expect(source).toMatch(/INSERT INTO audit_logs/i); expect(source).toMatch(/DELETE FROM booking_request_payment_allocations/i); expect(source).toMatch(/UPDATE booking_request_payment_allocations/i); expect(source).toMatch(/UPDATE booking_request_installments/i); + expect(source).toMatch(/allocated_amount\s+IS\s+DISTINCT\s+FROM/i); + expect(source).toMatch(/status\s+IS\s+DISTINCT\s+FROM/i); } }); }); diff --git a/packages/database/src/migrations/0024_booking_request_financial_recovery.sql b/packages/database/src/migrations/0024_booking_request_financial_recovery.sql index 0101a1b5..239a4d78 100644 --- a/packages/database/src/migrations/0024_booking_request_financial_recovery.sql +++ b/packages/database/src/migrations/0024_booking_request_financial_recovery.sql @@ -56,10 +56,15 @@ CREATE UNIQUE INDEX IF NOT EXISTS br_payment_resolutions_property_provider_tx_un ON booking_request_payment_resolutions (property_id, provider_transaction_id); CREATE UNIQUE INDEX IF NOT EXISTS payments_property_request_parent_id_unique ON payments (property_id, booking_request_id, original_payment_id, id); +CREATE UNIQUE INDEX IF NOT EXISTS audit_logs_booking_request_allocation_repair_unique + ON audit_logs (entity_type, entity_id, ((new_value ->> 'repairKey'))) + WHERE entity_type = 'booking_request_payment_allocation' + AND new_value ? 'repairKey'; -- booking_request_net_allocation_repair: releases allocations made stale by -- completed refund/return movements from the pre-reconciliation release. Rows --- are consumed deterministically by allocation creation order. +-- are consumed deterministically by allocation creation order. Audit evidence +-- and allocation changes share one statement, so neither can commit alone. WITH net_capacity AS ( SELECT parent.property_id, parent.booking_request_id, @@ -76,6 +81,11 @@ WITH net_capacity AS ( GROUP BY parent.property_id, parent.booking_request_id, parent.id, parent.amount ), ranked AS ( SELECT allocation.id, + allocation.property_id, + allocation.booking_request_id, + allocation.payment_id, + allocation.installment_id, + allocation.amount AS old_amount, capacity.net_amount, COALESCE(SUM(allocation.amount) OVER ( PARTITION BY allocation.property_id, allocation.booking_request_id, allocation.payment_id @@ -87,46 +97,67 @@ WITH net_capacity AS ( ON capacity.property_id = allocation.property_id AND capacity.booking_request_id = allocation.booking_request_id AND capacity.payment_id = allocation.payment_id +), changes AS ( + SELECT ranked.*, + GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0) + AS new_amount, + 'task7-net-allocation-v1:' || ranked.id::text || ':' + || ranked.old_amount::text || ':' + || GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0)::text + AS repair_key + FROM ranked + WHERE ranked.old_amount IS DISTINCT FROM + GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0) +), audit_evidence AS ( + INSERT INTO audit_logs ( + property_id, + action, + entity_type, + entity_id, + previous_value, + new_value, + description + ) + SELECT changes.property_id, + CASE WHEN changes.new_amount = 0 THEN 'delete' ELSE 'update' END, + 'booking_request_payment_allocation', + changes.id, + jsonb_build_object('amount', changes.old_amount::text), + jsonb_build_object( + 'repairKey', changes.repair_key, + 'bookingRequestId', changes.booking_request_id, + 'paymentId', changes.payment_id, + 'installmentId', changes.installment_id, + 'oldAmount', changes.old_amount::text, + 'newAmount', changes.new_amount::text + ), + 'System repaired Booking Request payment allocation to net captured capacity' + FROM changes + WHERE NOT EXISTS ( + SELECT 1 + FROM audit_logs existing + WHERE existing.entity_type = 'booking_request_payment_allocation' + AND existing.entity_id = changes.id + AND existing.new_value ->> 'repairKey' = changes.repair_key + ) + ON CONFLICT DO NOTHING + RETURNING entity_id +), deleted AS ( + DELETE FROM booking_request_payment_allocations allocation + USING changes + WHERE allocation.id = changes.id + AND changes.new_amount = 0 + RETURNING allocation.id +), updated AS ( + UPDATE booking_request_payment_allocations allocation + SET amount = changes.new_amount + FROM changes + WHERE allocation.id = changes.id + AND changes.new_amount > 0 + AND allocation.amount IS DISTINCT FROM changes.new_amount + RETURNING allocation.id ) -DELETE FROM booking_request_payment_allocations allocation -USING ranked -WHERE allocation.id = ranked.id - AND ranked.used_before >= ranked.net_amount; - -WITH net_capacity AS ( - SELECT parent.property_id, - parent.booking_request_id, - parent.id AS payment_id, - GREATEST(parent.amount + COALESCE(SUM(child.amount) - FILTER (WHERE child.status = 'captured'), 0), 0) AS net_amount - FROM payments parent - LEFT JOIN payments child - ON child.property_id = parent.property_id - AND child.booking_request_id = parent.booking_request_id - AND child.original_payment_id = parent.id - WHERE parent.booking_request_id IS NOT NULL - AND parent.original_payment_id IS NULL - GROUP BY parent.property_id, parent.booking_request_id, parent.id, parent.amount -), ranked AS ( - SELECT allocation.id, - allocation.amount, - capacity.net_amount, - COALESCE(SUM(allocation.amount) OVER ( - PARTITION BY allocation.property_id, allocation.booking_request_id, allocation.payment_id - ORDER BY allocation.created_at, allocation.id - ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING - ), 0) AS used_before - FROM booking_request_payment_allocations allocation - JOIN net_capacity capacity - ON capacity.property_id = allocation.property_id - AND capacity.booking_request_id = allocation.booking_request_id - AND capacity.payment_id = allocation.payment_id -) -UPDATE booking_request_payment_allocations allocation -SET amount = LEAST(ranked.amount, ranked.net_amount - ranked.used_before) -FROM ranked -WHERE allocation.id = ranked.id - AND ranked.amount > ranked.net_amount - ranked.used_before; +SELECT COUNT(*) FROM audit_evidence; WITH installment_totals AS ( SELECT installment.id, @@ -138,17 +169,26 @@ WITH installment_totals AS ( AND allocation.booking_request_id = installment.booking_request_id AND allocation.installment_id = installment.id GROUP BY installment.id, installment.resolved_amount +), derived AS ( + SELECT total.id, + LEAST(total.amount, total.resolved_amount) AS allocated_amount, + CASE + WHEN total.amount <= 0 THEN 'unpaid' + WHEN total.amount >= total.resolved_amount THEN 'paid' + ELSE 'partial' + END::booking_request_installment_status AS status + FROM installment_totals total ) UPDATE booking_request_installments installment -SET allocated_amount = LEAST(total.amount, total.resolved_amount), - status = CASE - WHEN total.amount <= 0 THEN 'unpaid' - WHEN total.amount >= total.resolved_amount THEN 'paid' - ELSE 'partial' - END::booking_request_installment_status, +SET allocated_amount = derived.allocated_amount, + status = derived.status, updated_at = now() -FROM installment_totals total -WHERE installment.id = total.id; +FROM derived +WHERE installment.id = derived.id + AND ( + installment.allocated_amount IS DISTINCT FROM derived.allocated_amount + OR installment.status IS DISTINCT FROM derived.status + ); ALTER TABLE booking_request_payment_resolutions DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_retained_reason_check, diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index 8ff8e095..09ad8188 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -1626,6 +1626,9 @@ async function main() { `CREATE UNIQUE INDEX IF NOT EXISTS booking_request_installments_property_request_id_unique ON booking_request_installments (property_id, booking_request_id, id)`, `CREATE UNIQUE INDEX IF NOT EXISTS payments_property_request_id_unique ON payments (property_id, booking_request_id, id)`, `CREATE UNIQUE INDEX IF NOT EXISTS payments_property_request_parent_id_unique ON payments (property_id, booking_request_id, original_payment_id, id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS audit_logs_booking_request_allocation_repair_unique + ON audit_logs (entity_type, entity_id, ((new_value ->> 'repairKey'))) + WHERE entity_type = 'booking_request_payment_allocation' AND new_value ? 'repairKey'`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_amount_kind_check') THEN ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_amount_kind_check @@ -1740,7 +1743,7 @@ async function main() { $booking_request_resolution_provenance$`, `CREATE UNIQUE INDEX IF NOT EXISTS br_payment_resolutions_property_provider_tx_unique ON booking_request_payment_resolutions (property_id, provider_transaction_id)`, - `-- booking_request_net_allocation_repair + `-- booking_request_net_allocation_repair: audited atomically and observationally idempotent WITH net_capacity AS ( SELECT parent.property_id, parent.booking_request_id, parent.id AS payment_id, GREATEST(parent.amount + COALESCE(SUM(child.amount) FILTER (WHERE child.status = 'captured'), 0), 0) AS net_amount @@ -1752,7 +1755,9 @@ async function main() { WHERE parent.booking_request_id IS NOT NULL AND parent.original_payment_id IS NULL GROUP BY parent.property_id, parent.booking_request_id, parent.id, parent.amount ), ranked AS ( - SELECT allocation.id, capacity.net_amount, + SELECT allocation.id, allocation.property_id, allocation.booking_request_id, + allocation.payment_id, allocation.installment_id, allocation.amount AS old_amount, + capacity.net_amount, COALESCE(SUM(allocation.amount) OVER ( PARTITION BY allocation.property_id, allocation.booking_request_id, allocation.payment_id ORDER BY allocation.created_at, allocation.id @@ -1763,37 +1768,56 @@ async function main() { ON capacity.property_id = allocation.property_id AND capacity.booking_request_id = allocation.booking_request_id AND capacity.payment_id = allocation.payment_id + ), changes AS ( + SELECT ranked.*, + GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0) AS new_amount, + 'task7-net-allocation-v1:' || ranked.id::text || ':' || ranked.old_amount::text || ':' + || GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0)::text AS repair_key + FROM ranked + WHERE ranked.old_amount IS DISTINCT FROM + GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0) + ), audit_evidence AS ( + INSERT INTO audit_logs ( + property_id, action, entity_type, entity_id, previous_value, new_value, description + ) + SELECT changes.property_id, + CASE WHEN changes.new_amount = 0 THEN 'delete' ELSE 'update' END, + 'booking_request_payment_allocation', + changes.id, + jsonb_build_object('amount', changes.old_amount::text), + jsonb_build_object( + 'repairKey', changes.repair_key, + 'bookingRequestId', changes.booking_request_id, + 'paymentId', changes.payment_id, + 'installmentId', changes.installment_id, + 'oldAmount', changes.old_amount::text, + 'newAmount', changes.new_amount::text + ), + 'System repaired Booking Request payment allocation to net captured capacity' + FROM changes + WHERE NOT EXISTS ( + SELECT 1 FROM audit_logs existing + WHERE existing.entity_type = 'booking_request_payment_allocation' + AND existing.entity_id = changes.id + AND existing.new_value ->> 'repairKey' = changes.repair_key + ) + ON CONFLICT DO NOTHING + RETURNING entity_id + ), deleted AS ( + DELETE FROM booking_request_payment_allocations allocation + USING changes + WHERE allocation.id = changes.id AND changes.new_amount = 0 + RETURNING allocation.id + ), updated AS ( + UPDATE booking_request_payment_allocations allocation + SET amount = changes.new_amount + FROM changes + WHERE allocation.id = changes.id + AND changes.new_amount > 0 + AND allocation.amount IS DISTINCT FROM changes.new_amount + RETURNING allocation.id ) - DELETE FROM booking_request_payment_allocations allocation - USING ranked - WHERE allocation.id = ranked.id AND ranked.used_before >= ranked.net_amount`, - `WITH net_capacity AS ( - SELECT parent.property_id, parent.booking_request_id, parent.id AS payment_id, - GREATEST(parent.amount + COALESCE(SUM(child.amount) FILTER (WHERE child.status = 'captured'), 0), 0) AS net_amount - FROM payments parent - LEFT JOIN payments child - ON child.property_id = parent.property_id - AND child.booking_request_id = parent.booking_request_id - AND child.original_payment_id = parent.id - WHERE parent.booking_request_id IS NOT NULL AND parent.original_payment_id IS NULL - GROUP BY parent.property_id, parent.booking_request_id, parent.id, parent.amount - ), ranked AS ( - SELECT allocation.id, allocation.amount, capacity.net_amount, - COALESCE(SUM(allocation.amount) OVER ( - PARTITION BY allocation.property_id, allocation.booking_request_id, allocation.payment_id - ORDER BY allocation.created_at, allocation.id - ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING - ), 0) AS used_before - FROM booking_request_payment_allocations allocation - JOIN net_capacity capacity - ON capacity.property_id = allocation.property_id - AND capacity.booking_request_id = allocation.booking_request_id - AND capacity.payment_id = allocation.payment_id - ) - UPDATE booking_request_payment_allocations allocation - SET amount = LEAST(ranked.amount, ranked.net_amount - ranked.used_before) - FROM ranked - WHERE allocation.id = ranked.id AND ranked.amount > ranked.net_amount - ranked.used_before`, + SELECT COUNT(*) FROM audit_evidence`, `WITH installment_totals AS ( SELECT installment.id, installment.resolved_amount, COALESCE(SUM(allocation.amount), 0) AS amount FROM booking_request_installments installment @@ -1802,17 +1826,26 @@ async function main() { AND allocation.booking_request_id = installment.booking_request_id AND allocation.installment_id = installment.id GROUP BY installment.id, installment.resolved_amount + ), derived AS ( + SELECT total.id, + LEAST(total.amount, total.resolved_amount) AS allocated_amount, + CASE + WHEN total.amount <= 0 THEN 'unpaid' + WHEN total.amount >= total.resolved_amount THEN 'paid' + ELSE 'partial' + END::booking_request_installment_status AS status + FROM installment_totals total ) UPDATE booking_request_installments installment - SET allocated_amount = LEAST(total.amount, total.resolved_amount), - status = CASE - WHEN total.amount <= 0 THEN 'unpaid' - WHEN total.amount >= total.resolved_amount THEN 'paid' - ELSE 'partial' - END::booking_request_installment_status, + SET allocated_amount = derived.allocated_amount, + status = derived.status, updated_at = now() - FROM installment_totals total - WHERE installment.id = total.id`, + FROM derived + WHERE installment.id = derived.id + AND ( + installment.allocated_amount IS DISTINCT FROM derived.allocated_amount + OR installment.status IS DISTINCT FROM derived.status + )`, `ALTER TABLE booking_request_payment_resolutions DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_retained_reason_check, DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_lifecycle_check`, From 4c97c42916a9207ee98c858a623ee673a1c0835d Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 23:46:33 +0200 Subject: [PATCH 26/87] fix(booking-requests): audit financial recovery repairs --- .../booking-request-payment.db.spec.ts | 85 +++++ .../payment/stripe-webhook.controller.ts | 72 ++++- .../modules/payment/stripe-webhook.spec.ts | 93 +++++- .../booking-request-migration-safety.spec.ts | 7 + ...024_booking_request_financial_recovery.sql | 301 +++++++++++------- packages/database/src/push-schema.ts | 175 +++++++--- 6 files changed, 553 insertions(+), 180 deletions(-) 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 index e0722f4e..731d26cd 100644 --- 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 @@ -537,6 +537,38 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = }), }), ]); + 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(); @@ -556,7 +588,60 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = 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 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(1); + expect(await db.select().from(auditLogs) + .where(eq(auditLogs.entityId, repairInstallmentId))).toHaveLength(1); }); }); diff --git a/apps/api/src/modules/payment/stripe-webhook.controller.ts b/apps/api/src/modules/payment/stripe-webhook.controller.ts index c55e0078..72042f57 100644 --- a/apps/api/src/modules/payment/stripe-webhook.controller.ts +++ b/apps/api/src/modules/payment/stripe-webhook.controller.ts @@ -235,7 +235,25 @@ export class StripeWebhookController { 'Stripe PaymentIntent does not match the provider identity already bound to the payment', ); } - this.assertPaymentIntentBindingIdentity(pi, event, payment, request); + } + + if (request && (event === 'processing' || event === 'succeeded')) { + 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) @@ -251,6 +269,21 @@ export class StripeWebhookController { 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; } } @@ -324,6 +357,15 @@ export class StripeWebhookController { .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') { @@ -368,6 +410,9 @@ export class StripeWebhookController { '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, @@ -703,6 +748,31 @@ export class StripeWebhookController { }); } + 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, diff --git a/apps/api/src/modules/payment/stripe-webhook.spec.ts b/apps/api/src/modules/payment/stripe-webhook.spec.ts index 3188fc80..a2f45e17 100644 --- a/apps/api/src/modules/payment/stripe-webhook.spec.ts +++ b/apps/api/src/modules/payment/stripe-webhook.spec.ts @@ -38,6 +38,9 @@ function request(overrides: Record = {}) { propertyId: PROPERTY_ID, status: 'pending', acceptedFolioId: null, + currencyCode: 'USD', + stripeCustomerId: 'cus_saved', + stripePaymentMethodId: 'pm_saved', ...overrides, }; } @@ -79,6 +82,16 @@ function unknownPaymentIntent(overrides: Record = {}) { }; } +function knownPaymentIntent(overrides: Record = {}) { + return unknownPaymentIntent({ + id: 'pi_request_1', + amount: 10000, + amount_received: 10000, + metadata: {}, + ...overrides, + }); +} + function resolution(id: string, overrides: Record = {}) { return { id, @@ -291,7 +304,7 @@ describe('StripeWebhookController financial finalization', () => { it('finalizes a pending request PaymentIntent under request→payment locks with fresh folio', async () => { const h = await harness({ requests: [request({ status: 'accepted', acceptedFolioId: FOLIO_ID })] }); - await h.controller.handlePaymentIntentSucceeded({ id: 'pi_request_1' }); + await h.controller.handlePaymentIntentSucceeded(knownPaymentIntent()); expect(h.state.payments[0]).toMatchObject({ status: 'captured', folioId: FOLIO_ID }); expect(h.state.consequences).toEqual([ expect.objectContaining({ kind: expect.stringMatching(/^payment_received:/), status: 'pending' }), @@ -397,6 +410,74 @@ describe('StripeWebhookController financial finalization', () => { expect(h.state.consequences).toHaveLength(0); }); + it('audits provider binding once and audits a later folio relink only when it changes', async () => { + const h = await harness({ + requests: [request({ status: 'accepted', acceptedFolioId: FOLIO_ID })], + payments: [payment({ gatewayTransactionId: null, amount: '25.00' })], + }); + const pi = unknownPaymentIntent({ amount_received: 0 }); + + await h.controller.handlePaymentIntentProcessing(pi); + await h.controller.handlePaymentIntentProcessing(pi); + expect(h.state.audits).toEqual([ + expect.objectContaining({ + propertyId: PROPERTY_ID, + previousValue: expect.objectContaining({ + bookingRequestId: REQUEST_ID, + gatewayTransactionId: null, + }), + newValue: expect.objectContaining({ + bookingRequestId: REQUEST_ID, + gatewayTransactionId: pi.id, + }), + }), + expect.objectContaining({ + propertyId: PROPERTY_ID, + previousValue: expect.objectContaining({ bookingRequestId: REQUEST_ID, folioId: null }), + newValue: expect.objectContaining({ bookingRequestId: REQUEST_ID, folioId: FOLIO_ID }), + }), + ]); + + await h.controller.handlePaymentIntentProcessing(pi); + expect(h.state.audits).toHaveLength(2); + }); + + it.each([ + ['received amount', { amount_received: 9900 }], + ['currency', { currency: 'eur' }], + ['customer', { customer: 'cus_wrong' }], + ['payment method', { payment_method: 'pm_wrong' }], + ])('rejects an existing-id succeeded event with wrong %s and audits without mutation', async ( + _label, + succeededOverrides, + ) => { + const h = await harness(); + await h.controller.handlePaymentIntentProcessing(knownPaymentIntent({ amount_received: 0 })); + const before = structuredClone(h.state.payments[0]); + + await expect(h.controller.handlePaymentIntentSucceeded( + knownPaymentIntent(succeededOverrides), + )).rejects.toThrow(/amount|currency|customer|payment method|identity/i); + + expect(h.state.payments[0]).toEqual(before); + expect(h.state.consequences).toHaveLength(0); + expect(h.state.audits).toEqual([ + expect.objectContaining({ + propertyId: PROPERTY_ID, + entityId: PAYMENT_ID, + previousValue: expect.objectContaining({ + bookingRequestId: REQUEST_ID, + status: 'pending', + }), + newValue: expect.objectContaining({ + bookingRequestId: REQUEST_ID, + providerEvent: 'succeeded', + reason: expect.stringMatching(/identity/i), + }), + }), + ]); + }); + it.each([ ['missing', undefined], ['cross-property', { @@ -448,8 +529,8 @@ describe('StripeWebhookController financial finalization', () => { requests: [request({ status: 'accepted', acceptedFolioId: FOLIO_ID })], payments: [payment({ status: 'captured', folioId: null })], }); - await h.controller.handlePaymentIntentSucceeded({ id: 'pi_request_1' }); - await h.controller.handlePaymentIntentSucceeded({ id: 'pi_request_1' }); + await h.controller.handlePaymentIntentSucceeded(knownPaymentIntent()); + await h.controller.handlePaymentIntentSucceeded(knownPaymentIntent()); expect(h.state.payments[0]!.folioId).toBe(FOLIO_ID); expect(h.state.consequences).toHaveLength(1); expect(h.folioService.recalculateBalance).toHaveBeenCalledTimes(2); @@ -457,7 +538,7 @@ describe('StripeWebhookController financial finalization', () => { it.each(['failed', 'voided'] as const)('does not regress terminal %s to captured', async (status) => { const h = await harness({ payments: [payment({ status })] }); - await h.controller.handlePaymentIntentSucceeded({ id: 'pi_request_1' }); + await h.controller.handlePaymentIntentSucceeded(knownPaymentIntent()); expect(h.state.payments[0]!.status).toBe(status); expect(h.state.consequences).toHaveLength(0); expect(h.state.audits).toEqual(expect.arrayContaining([ @@ -467,7 +548,7 @@ describe('StripeWebhookController financial finalization', () => { it('durably audits and rejects a capture reported after denial', async () => { const h = await harness({ requests: [request({ status: 'denied' })] }); - await expect(h.controller.handlePaymentIntentSucceeded({ id: 'pi_request_1' })) + await expect(h.controller.handlePaymentIntentSucceeded(knownPaymentIntent())) .rejects.toThrow(/denial|denied/i); expect(h.state.payments[0]!.status).toBe('pending'); expect(h.state.audits).toHaveLength(1); @@ -479,7 +560,7 @@ describe('StripeWebhookController financial finalization', () => { await h.controller[method]({ id: 'pi_request_1', last_payment_error: { message: 'Declined' } }); expect(h.state.payments[0]!.status).toBe('failed'); expect(h.state.consequences[0]!.kind).toMatch(/^payment_failed:/); - await h.controller.handlePaymentIntentSucceeded({ id: 'pi_request_1' }); + await h.controller.handlePaymentIntentSucceeded(knownPaymentIntent()); expect(h.state.payments[0]!.status).toBe('failed'); } }); diff --git a/packages/database/src/booking-request-migration-safety.spec.ts b/packages/database/src/booking-request-migration-safety.spec.ts index c78c91e9..d5bb001f 100644 --- a/packages/database/src/booking-request-migration-safety.spec.ts +++ b/packages/database/src/booking-request-migration-safety.spec.ts @@ -81,7 +81,14 @@ describe('booking request payment integrity migration safety', () => { for (const source of [financialRecoveryMigration, pushSchema]) { expect(source).toContain('booking_request_net_allocation_repair'); expect(source).toContain('task7-net-allocation-v1:'); + expect(source).toContain('task7-installment-derived-v1:'); expect(source).toContain('audit_logs_booking_request_allocation_repair_unique'); + expect(source).toContain('audit_logs_booking_request_installment_repair_unique'); + expect(source).toContain('booking_request_financial_repair_lock'); + expect(source).toMatch(/ORDER BY parent\.property_id, parent\.booking_request_id, parent\.id[\s\S]*FOR UPDATE/i); + expect(source).toMatch(/ORDER BY allocation\.property_id, allocation\.booking_request_id,[\s\S]*FOR UPDATE/i); + expect(source).toMatch(/ORDER BY installment\.property_id, installment\.booking_request_id, installment\.id[\s\S]*FOR UPDATE/i); + expect(source).toMatch(/RETURNING[\s\S]*old_amount[\s\S]*new_amount/i); expect(source).toMatch(/INSERT INTO audit_logs/i); expect(source).toMatch(/DELETE FROM booking_request_payment_allocations/i); expect(source).toMatch(/UPDATE booking_request_payment_allocations/i); diff --git a/packages/database/src/migrations/0024_booking_request_financial_recovery.sql b/packages/database/src/migrations/0024_booking_request_financial_recovery.sql index 239a4d78..7354424f 100644 --- a/packages/database/src/migrations/0024_booking_request_financial_recovery.sql +++ b/packages/database/src/migrations/0024_booking_request_financial_recovery.sql @@ -60,135 +60,196 @@ CREATE UNIQUE INDEX IF NOT EXISTS audit_logs_booking_request_allocation_repair_u ON audit_logs (entity_type, entity_id, ((new_value ->> 'repairKey'))) WHERE entity_type = 'booking_request_payment_allocation' AND new_value ? 'repairKey'; +CREATE UNIQUE INDEX IF NOT EXISTS audit_logs_booking_request_installment_repair_unique + ON audit_logs (entity_type, entity_id, ((new_value ->> 'repairKey'))) + WHERE entity_type = 'booking_request_installment' + AND new_value ? 'repairKey'; -- booking_request_net_allocation_repair: releases allocations made stale by -- completed refund/return movements from the pre-reconciliation release. Rows --- are consumed deterministically by allocation creation order. Audit evidence --- and allocation changes share one statement, so neither can commit alone. -WITH net_capacity AS ( - SELECT parent.property_id, - parent.booking_request_id, - parent.id AS payment_id, - GREATEST(parent.amount + COALESCE(SUM(child.amount) - FILTER (WHERE child.status = 'captured'), 0), 0) AS net_amount +-- are consumed deterministically by allocation creation order. The DO block is +-- one transaction scope: parent, allocation, and installment locks remain held +-- through both repairs, and audits are sourced only from DML RETURNING rows. +DO $booking_request_financial_repair_lock$ +DECLARE + repaired_count bigint; +BEGIN + PERFORM parent.id FROM payments parent - LEFT JOIN payments child - ON child.property_id = parent.property_id - AND child.booking_request_id = parent.booking_request_id - AND child.original_payment_id = parent.id WHERE parent.booking_request_id IS NOT NULL AND parent.original_payment_id IS NULL - GROUP BY parent.property_id, parent.booking_request_id, parent.id, parent.amount -), ranked AS ( - SELECT allocation.id, - allocation.property_id, - allocation.booking_request_id, - allocation.payment_id, - allocation.installment_id, - allocation.amount AS old_amount, - capacity.net_amount, - COALESCE(SUM(allocation.amount) OVER ( - PARTITION BY allocation.property_id, allocation.booking_request_id, allocation.payment_id - ORDER BY allocation.created_at, allocation.id - ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING - ), 0) AS used_before + ORDER BY parent.property_id, parent.booking_request_id, parent.id + FOR UPDATE; + + PERFORM allocation.id FROM booking_request_payment_allocations allocation - JOIN net_capacity capacity - ON capacity.property_id = allocation.property_id - AND capacity.booking_request_id = allocation.booking_request_id - AND capacity.payment_id = allocation.payment_id -), changes AS ( - SELECT ranked.*, - GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0) - AS new_amount, - 'task7-net-allocation-v1:' || ranked.id::text || ':' - || ranked.old_amount::text || ':' - || GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0)::text - AS repair_key - FROM ranked - WHERE ranked.old_amount IS DISTINCT FROM - GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0) -), audit_evidence AS ( - INSERT INTO audit_logs ( - property_id, - action, - entity_type, - entity_id, - previous_value, - new_value, - description - ) - SELECT changes.property_id, - CASE WHEN changes.new_amount = 0 THEN 'delete' ELSE 'update' END, - 'booking_request_payment_allocation', - changes.id, - jsonb_build_object('amount', changes.old_amount::text), - jsonb_build_object( - 'repairKey', changes.repair_key, - 'bookingRequestId', changes.booking_request_id, - 'paymentId', changes.payment_id, - 'installmentId', changes.installment_id, - 'oldAmount', changes.old_amount::text, - 'newAmount', changes.new_amount::text - ), - 'System repaired Booking Request payment allocation to net captured capacity' - FROM changes - WHERE NOT EXISTS ( - SELECT 1 - FROM audit_logs existing - WHERE existing.entity_type = 'booking_request_payment_allocation' - AND existing.entity_id = changes.id - AND existing.new_value ->> 'repairKey' = changes.repair_key - ) - ON CONFLICT DO NOTHING - RETURNING entity_id -), deleted AS ( - DELETE FROM booking_request_payment_allocations allocation - USING changes - WHERE allocation.id = changes.id - AND changes.new_amount = 0 - RETURNING allocation.id -), updated AS ( - UPDATE booking_request_payment_allocations allocation - SET amount = changes.new_amount - FROM changes - WHERE allocation.id = changes.id - AND changes.new_amount > 0 - AND allocation.amount IS DISTINCT FROM changes.new_amount - RETURNING allocation.id -) -SELECT COUNT(*) FROM audit_evidence; + ORDER BY allocation.property_id, allocation.booking_request_id, + allocation.payment_id, allocation.created_at, allocation.id + FOR UPDATE; -WITH installment_totals AS ( - SELECT installment.id, - installment.resolved_amount, - COALESCE(SUM(allocation.amount), 0) AS amount + PERFORM installment.id FROM booking_request_installments installment - LEFT JOIN booking_request_payment_allocations allocation - ON allocation.property_id = installment.property_id - AND allocation.booking_request_id = installment.booking_request_id - AND allocation.installment_id = installment.id - GROUP BY installment.id, installment.resolved_amount -), derived AS ( - SELECT total.id, - LEAST(total.amount, total.resolved_amount) AS allocated_amount, - CASE - WHEN total.amount <= 0 THEN 'unpaid' - WHEN total.amount >= total.resolved_amount THEN 'paid' - ELSE 'partial' - END::booking_request_installment_status AS status - FROM installment_totals total -) -UPDATE booking_request_installments installment -SET allocated_amount = derived.allocated_amount, - status = derived.status, - updated_at = now() -FROM derived -WHERE installment.id = derived.id - AND ( - installment.allocated_amount IS DISTINCT FROM derived.allocated_amount - OR installment.status IS DISTINCT FROM derived.status - ); + ORDER BY installment.property_id, installment.booking_request_id, installment.id + FOR UPDATE; + + WITH net_capacity AS ( + SELECT parent.property_id, + parent.booking_request_id, + parent.id AS payment_id, + GREATEST(parent.amount + COALESCE(SUM(child.amount) + FILTER (WHERE child.status = 'captured'), 0), 0) AS net_amount + FROM payments parent + LEFT JOIN payments child + ON child.property_id = parent.property_id + AND child.booking_request_id = parent.booking_request_id + AND child.original_payment_id = parent.id + WHERE parent.booking_request_id IS NOT NULL + AND parent.original_payment_id IS NULL + GROUP BY parent.property_id, parent.booking_request_id, parent.id, parent.amount + ), ranked AS ( + SELECT allocation.id, + allocation.property_id, + allocation.booking_request_id, + allocation.payment_id, + allocation.installment_id, + allocation.amount AS old_amount, + capacity.net_amount, + COALESCE(SUM(allocation.amount) OVER ( + PARTITION BY allocation.property_id, allocation.booking_request_id, allocation.payment_id + ORDER BY allocation.created_at, allocation.id + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ), 0) AS used_before + FROM booking_request_payment_allocations allocation + JOIN net_capacity capacity + ON capacity.property_id = allocation.property_id + AND capacity.booking_request_id = allocation.booking_request_id + AND capacity.payment_id = allocation.payment_id + ), changes AS ( + SELECT ranked.*, + GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0) + AS new_amount + FROM ranked + WHERE ranked.old_amount > + GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0) + ), deleted AS ( + DELETE FROM booking_request_payment_allocations allocation + USING changes + WHERE allocation.id = changes.id + AND allocation.amount = changes.old_amount + AND changes.new_amount = 0 + RETURNING allocation.id, allocation.property_id, allocation.booking_request_id, + allocation.payment_id, allocation.installment_id, + changes.old_amount, changes.new_amount + ), updated AS ( + UPDATE booking_request_payment_allocations allocation + SET amount = changes.new_amount + FROM changes + WHERE allocation.id = changes.id + AND allocation.amount = changes.old_amount + AND changes.new_amount > 0 + AND changes.new_amount < changes.old_amount + RETURNING allocation.id, allocation.property_id, allocation.booking_request_id, + allocation.payment_id, allocation.installment_id, + changes.old_amount, changes.new_amount + ), mutations AS ( + SELECT * FROM deleted + UNION ALL + SELECT * FROM updated + ), audit_evidence AS ( + INSERT INTO audit_logs ( + property_id, action, entity_type, entity_id, previous_value, new_value, description + ) + SELECT mutation.property_id, + CASE WHEN mutation.new_amount = 0 THEN 'delete' ELSE 'update' END, + 'booking_request_payment_allocation', + mutation.id, + jsonb_build_object('amount', mutation.old_amount::text), + jsonb_build_object( + 'repairKey', 'task7-net-allocation-v1:' || mutation.id::text || ':' + || mutation.old_amount::text || ':' || mutation.new_amount::text, + 'bookingRequestId', mutation.booking_request_id, + 'paymentId', mutation.payment_id, + 'installmentId', mutation.installment_id, + 'oldAmount', mutation.old_amount::text, + 'newAmount', mutation.new_amount::text + ), + 'System repaired Booking Request payment allocation to net captured capacity' + FROM mutations mutation + ON CONFLICT DO NOTHING + RETURNING entity_id + ) + SELECT COUNT(*) INTO repaired_count FROM audit_evidence; + + WITH installment_totals AS ( + SELECT installment.id, + installment.property_id, + installment.booking_request_id, + installment.allocated_amount AS old_allocated_amount, + installment.status AS old_status, + installment.resolved_amount, + COALESCE(SUM(allocation.amount), 0) AS amount + FROM booking_request_installments installment + LEFT JOIN booking_request_payment_allocations allocation + ON allocation.property_id = installment.property_id + AND allocation.booking_request_id = installment.booking_request_id + AND allocation.installment_id = installment.id + GROUP BY installment.id, installment.property_id, installment.booking_request_id, + installment.allocated_amount, installment.status, installment.resolved_amount + ), changes AS ( + SELECT total.*, + LEAST(total.amount, total.resolved_amount)::numeric(12,2) AS new_allocated_amount, + CASE + WHEN total.amount <= 0 THEN 'unpaid' + WHEN total.amount >= total.resolved_amount THEN 'paid' + ELSE 'partial' + END::booking_request_installment_status AS new_status + FROM installment_totals total + ), updated AS ( + UPDATE booking_request_installments installment + SET allocated_amount = changes.new_allocated_amount, + status = changes.new_status, + updated_at = now() + FROM changes + WHERE installment.id = changes.id + AND installment.allocated_amount = changes.old_allocated_amount + AND installment.status = changes.old_status + AND ( + changes.old_allocated_amount IS DISTINCT FROM changes.new_allocated_amount + OR changes.old_status IS DISTINCT FROM changes.new_status + ) + RETURNING installment.id, installment.property_id, installment.booking_request_id, + changes.old_allocated_amount, changes.old_status, + changes.new_allocated_amount, changes.new_status + ), audit_evidence AS ( + INSERT INTO audit_logs ( + property_id, action, entity_type, entity_id, previous_value, new_value, description + ) + SELECT repaired.property_id, + 'update', + 'booking_request_installment', + repaired.id, + jsonb_build_object( + 'allocatedAmount', repaired.old_allocated_amount::text, + 'status', repaired.old_status + ), + jsonb_build_object( + 'repairKey', 'task7-installment-derived-v1:' || repaired.id::text || ':' + || repaired.old_allocated_amount::text || ':' || repaired.old_status::text || ':' + || repaired.new_allocated_amount::text || ':' || repaired.new_status::text, + 'bookingRequestId', repaired.booking_request_id, + 'oldAllocatedAmount', repaired.old_allocated_amount::text, + 'newAllocatedAmount', repaired.new_allocated_amount::text, + 'oldStatus', repaired.old_status, + 'newStatus', repaired.new_status + ), + 'System repaired Booking Request installment derived payment state' + FROM updated repaired + ON CONFLICT DO NOTHING + RETURNING entity_id + ) + SELECT COUNT(*) INTO repaired_count FROM audit_evidence; +END +$booking_request_financial_repair_lock$; ALTER TABLE booking_request_payment_resolutions DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_retained_reason_check, diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index 09ad8188..fdb296cd 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -1629,6 +1629,9 @@ async function main() { `CREATE UNIQUE INDEX IF NOT EXISTS audit_logs_booking_request_allocation_repair_unique ON audit_logs (entity_type, entity_id, ((new_value ->> 'repairKey'))) WHERE entity_type = 'booking_request_payment_allocation' AND new_value ? 'repairKey'`, + `CREATE UNIQUE INDEX IF NOT EXISTS audit_logs_booking_request_installment_repair_unique + ON audit_logs (entity_type, entity_id, ((new_value ->> 'repairKey'))) + WHERE entity_type = 'booking_request_installment' AND new_value ? 'repairKey'`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_amount_kind_check') THEN ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_amount_kind_check @@ -1743,7 +1746,28 @@ async function main() { $booking_request_resolution_provenance$`, `CREATE UNIQUE INDEX IF NOT EXISTS br_payment_resolutions_property_provider_tx_unique ON booking_request_payment_resolutions (property_id, provider_transaction_id)`, - `-- booking_request_net_allocation_repair: audited atomically and observationally idempotent + `-- booking_request_net_allocation_repair: locked, causally exact, and audited from RETURNING + DO $booking_request_financial_repair_lock$ + DECLARE + repaired_count bigint; + BEGIN + PERFORM parent.id + FROM payments parent + WHERE parent.booking_request_id IS NOT NULL AND parent.original_payment_id IS NULL + ORDER BY parent.property_id, parent.booking_request_id, parent.id + FOR UPDATE; + + PERFORM allocation.id + FROM booking_request_payment_allocations allocation + ORDER BY allocation.property_id, allocation.booking_request_id, + allocation.payment_id, allocation.created_at, allocation.id + FOR UPDATE; + + PERFORM installment.id + FROM booking_request_installments installment + ORDER BY installment.property_id, installment.booking_request_id, installment.id + FOR UPDATE; + WITH net_capacity AS ( SELECT parent.property_id, parent.booking_request_id, parent.id AS payment_id, GREATEST(parent.amount + COALESCE(SUM(child.amount) FILTER (WHERE child.status = 'captured'), 0), 0) AS net_amount @@ -1770,82 +1794,127 @@ async function main() { AND capacity.payment_id = allocation.payment_id ), changes AS ( SELECT ranked.*, - GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0) AS new_amount, - 'task7-net-allocation-v1:' || ranked.id::text || ':' || ranked.old_amount::text || ':' - || GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0)::text AS repair_key + GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0) AS new_amount FROM ranked - WHERE ranked.old_amount IS DISTINCT FROM + WHERE ranked.old_amount > GREATEST(LEAST(ranked.old_amount, ranked.net_amount - ranked.used_before), 0) + ), deleted AS ( + DELETE FROM booking_request_payment_allocations allocation + USING changes + WHERE allocation.id = changes.id + AND allocation.amount = changes.old_amount + AND changes.new_amount = 0 + RETURNING allocation.id, allocation.property_id, allocation.booking_request_id, + allocation.payment_id, allocation.installment_id, + changes.old_amount, changes.new_amount + ), updated AS ( + UPDATE booking_request_payment_allocations allocation + SET amount = changes.new_amount + FROM changes + WHERE allocation.id = changes.id + AND allocation.amount = changes.old_amount + AND changes.new_amount > 0 + AND changes.new_amount < changes.old_amount + RETURNING allocation.id, allocation.property_id, allocation.booking_request_id, + allocation.payment_id, allocation.installment_id, + changes.old_amount, changes.new_amount + ), mutations AS ( + SELECT * FROM deleted + UNION ALL + SELECT * FROM updated ), audit_evidence AS ( INSERT INTO audit_logs ( property_id, action, entity_type, entity_id, previous_value, new_value, description ) - SELECT changes.property_id, - CASE WHEN changes.new_amount = 0 THEN 'delete' ELSE 'update' END, + SELECT mutation.property_id, + CASE WHEN mutation.new_amount = 0 THEN 'delete' ELSE 'update' END, 'booking_request_payment_allocation', - changes.id, - jsonb_build_object('amount', changes.old_amount::text), + mutation.id, + jsonb_build_object('amount', mutation.old_amount::text), jsonb_build_object( - 'repairKey', changes.repair_key, - 'bookingRequestId', changes.booking_request_id, - 'paymentId', changes.payment_id, - 'installmentId', changes.installment_id, - 'oldAmount', changes.old_amount::text, - 'newAmount', changes.new_amount::text + 'repairKey', 'task7-net-allocation-v1:' || mutation.id::text || ':' + || mutation.old_amount::text || ':' || mutation.new_amount::text, + 'bookingRequestId', mutation.booking_request_id, + 'paymentId', mutation.payment_id, + 'installmentId', mutation.installment_id, + 'oldAmount', mutation.old_amount::text, + 'newAmount', mutation.new_amount::text ), 'System repaired Booking Request payment allocation to net captured capacity' - FROM changes - WHERE NOT EXISTS ( - SELECT 1 FROM audit_logs existing - WHERE existing.entity_type = 'booking_request_payment_allocation' - AND existing.entity_id = changes.id - AND existing.new_value ->> 'repairKey' = changes.repair_key - ) + FROM mutations mutation ON CONFLICT DO NOTHING RETURNING entity_id - ), deleted AS ( - DELETE FROM booking_request_payment_allocations allocation - USING changes - WHERE allocation.id = changes.id AND changes.new_amount = 0 - RETURNING allocation.id - ), updated AS ( - UPDATE booking_request_payment_allocations allocation - SET amount = changes.new_amount - FROM changes - WHERE allocation.id = changes.id - AND changes.new_amount > 0 - AND allocation.amount IS DISTINCT FROM changes.new_amount - RETURNING allocation.id ) - SELECT COUNT(*) FROM audit_evidence`, - `WITH installment_totals AS ( - SELECT installment.id, installment.resolved_amount, COALESCE(SUM(allocation.amount), 0) AS amount + SELECT COUNT(*) INTO repaired_count FROM audit_evidence; + + WITH installment_totals AS ( + SELECT installment.id, installment.property_id, installment.booking_request_id, + installment.allocated_amount AS old_allocated_amount, + installment.status AS old_status, + installment.resolved_amount, + COALESCE(SUM(allocation.amount), 0) AS amount FROM booking_request_installments installment LEFT JOIN booking_request_payment_allocations allocation ON allocation.property_id = installment.property_id AND allocation.booking_request_id = installment.booking_request_id AND allocation.installment_id = installment.id - GROUP BY installment.id, installment.resolved_amount - ), derived AS ( - SELECT total.id, - LEAST(total.amount, total.resolved_amount) AS allocated_amount, + GROUP BY installment.id, installment.property_id, installment.booking_request_id, + installment.allocated_amount, installment.status, installment.resolved_amount + ), changes AS ( + SELECT total.*, + LEAST(total.amount, total.resolved_amount)::numeric(12,2) AS new_allocated_amount, CASE WHEN total.amount <= 0 THEN 'unpaid' WHEN total.amount >= total.resolved_amount THEN 'paid' ELSE 'partial' - END::booking_request_installment_status AS status + END::booking_request_installment_status AS new_status FROM installment_totals total + ), updated AS ( + UPDATE booking_request_installments installment + SET allocated_amount = changes.new_allocated_amount, + status = changes.new_status, + updated_at = now() + FROM changes + WHERE installment.id = changes.id + AND installment.allocated_amount = changes.old_allocated_amount + AND installment.status = changes.old_status + AND ( + changes.old_allocated_amount IS DISTINCT FROM changes.new_allocated_amount + OR changes.old_status IS DISTINCT FROM changes.new_status + ) + RETURNING installment.id, installment.property_id, installment.booking_request_id, + changes.old_allocated_amount, changes.old_status, + changes.new_allocated_amount, changes.new_status + ), audit_evidence AS ( + INSERT INTO audit_logs ( + property_id, action, entity_type, entity_id, previous_value, new_value, description + ) + SELECT repaired.property_id, + 'update', + 'booking_request_installment', + repaired.id, + jsonb_build_object( + 'allocatedAmount', repaired.old_allocated_amount::text, + 'status', repaired.old_status + ), + jsonb_build_object( + 'repairKey', 'task7-installment-derived-v1:' || repaired.id::text || ':' + || repaired.old_allocated_amount::text || ':' || repaired.old_status::text || ':' + || repaired.new_allocated_amount::text || ':' || repaired.new_status::text, + 'bookingRequestId', repaired.booking_request_id, + 'oldAllocatedAmount', repaired.old_allocated_amount::text, + 'newAllocatedAmount', repaired.new_allocated_amount::text, + 'oldStatus', repaired.old_status, + 'newStatus', repaired.new_status + ), + 'System repaired Booking Request installment derived payment state' + FROM updated repaired + ON CONFLICT DO NOTHING + RETURNING entity_id ) - UPDATE booking_request_installments installment - SET allocated_amount = derived.allocated_amount, - status = derived.status, - updated_at = now() - FROM derived - WHERE installment.id = derived.id - AND ( - installment.allocated_amount IS DISTINCT FROM derived.allocated_amount - OR installment.status IS DISTINCT FROM derived.status - )`, + SELECT COUNT(*) INTO repaired_count FROM audit_evidence; + END + $booking_request_financial_repair_lock$`, `ALTER TABLE booking_request_payment_resolutions DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_retained_reason_check, DROP CONSTRAINT IF EXISTS booking_request_payment_resolutions_lifecycle_check`, From f7ceeaf1f14ce42c5c4a0a4328fb211e6f7c3f13 Mon Sep 17 00:00:00 2001 From: Agus Date: Mon, 24 Aug 2026 23:55:35 +0200 Subject: [PATCH 27/87] fix(booking-requests): preserve financial audit history --- .../booking-request-payment.db.spec.ts | 34 +++++++++++++- .../payment/stripe-webhook.controller.ts | 2 +- .../modules/payment/stripe-webhook.spec.ts | 47 ++++++++++++++++++- .../booking-request-migration-safety.spec.ts | 9 +++- ...024_booking_request_financial_recovery.sql | 12 +---- packages/database/src/push-schema.ts | 10 +--- 6 files changed, 90 insertions(+), 24 deletions(-) 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 index 731d26cd..96fdf5be 100644 --- 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 @@ -597,6 +597,36 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = 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; @@ -640,8 +670,8 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = updatedAt: runtimeTimestamp, }); expect(await db.select().from(auditLogs) - .where(eq(auditLogs.entityId, repairAllocationId))).toHaveLength(1); + .where(eq(auditLogs.entityId, repairAllocationId))).toHaveLength(2); expect(await db.select().from(auditLogs) - .where(eq(auditLogs.entityId, repairInstallmentId))).toHaveLength(1); + .where(eq(auditLogs.entityId, repairInstallmentId))).toHaveLength(2); }); }); diff --git a/apps/api/src/modules/payment/stripe-webhook.controller.ts b/apps/api/src/modules/payment/stripe-webhook.controller.ts index 72042f57..5020a9d1 100644 --- a/apps/api/src/modules/payment/stripe-webhook.controller.ts +++ b/apps/api/src/modules/payment/stripe-webhook.controller.ts @@ -237,7 +237,7 @@ export class StripeWebhookController { } } - if (request && (event === 'processing' || event === 'succeeded')) { + if (request) { try { this.assertPaymentIntentBindingIdentity(pi, event, payment, request); } catch (error) { diff --git a/apps/api/src/modules/payment/stripe-webhook.spec.ts b/apps/api/src/modules/payment/stripe-webhook.spec.ts index a2f45e17..7fe9bb34 100644 --- a/apps/api/src/modules/payment/stripe-webhook.spec.ts +++ b/apps/api/src/modules/payment/stripe-webhook.spec.ts @@ -410,6 +410,49 @@ describe('StripeWebhookController financial finalization', () => { expect(h.state.consequences).toHaveLength(0); }); + it.each([ + ['handlePaymentIntentProcessing', { amount: 2600 }], + ['handlePaymentIntentProcessing', { currency: 'eur' }], + ['handlePaymentIntentProcessing', { customer: 'cus_wrong' }], + ['handlePaymentIntentProcessing', { payment_method: 'pm_wrong' }], + ['handlePaymentIntentSucceeded', { amount: 2600 }], + ['handlePaymentIntentSucceeded', { currency: 'eur' }], + ['handlePaymentIntentSucceeded', { customer: 'cus_wrong' }], + ['handlePaymentIntentSucceeded', { payment_method: 'pm_wrong' }], + ['handlePaymentIntentFailed', { amount: 2600 }], + ['handlePaymentIntentFailed', { currency: 'eur' }], + ['handlePaymentIntentFailed', { customer: 'cus_wrong' }], + ['handlePaymentIntentFailed', { payment_method: 'pm_wrong' }], + ['handlePaymentIntentCanceled', { amount: 2600 }], + ['handlePaymentIntentCanceled', { currency: 'eur' }], + ['handlePaymentIntentCanceled', { customer: 'cus_wrong' }], + ['handlePaymentIntentCanceled', { payment_method: 'pm_wrong' }], + ['handlePaymentIntentRequiresAction', { amount: 2600 }], + ['handlePaymentIntentRequiresAction', { currency: 'eur' }], + ['handlePaymentIntentRequiresAction', { customer: 'cus_wrong' }], + ['handlePaymentIntentRequiresAction', { payment_method: 'pm_wrong' }], + ] as const)('%s rejects copied metadata identity before binding', async (method, overrides) => { + const h = await harness({ + payments: [payment({ gatewayTransactionId: null, amount: '25.00' })], + }); + + await expect(h.controller[method](unknownPaymentIntent(overrides))) + .rejects.toThrow(/amount|currency|customer|payment method|identity/i); + + expect(h.state.payments[0]).toMatchObject({ + status: 'pending', + gatewayTransactionId: null, + }); + expect(h.state.consequences).toHaveLength(0); + expect(h.state.audits).toEqual([ + expect.objectContaining({ + propertyId: PROPERTY_ID, + entityId: PAYMENT_ID, + description: expect.stringMatching(/identity mismatch/i), + }), + ]); + }); + it('audits provider binding once and audits a later folio relink only when it changes', async () => { const h = await harness({ requests: [request({ status: 'accepted', acceptedFolioId: FOLIO_ID })], @@ -557,7 +600,9 @@ describe('StripeWebhookController financial finalization', () => { it('makes provider failure/requires-action terminal and emits a durable failed consequence', async () => { for (const method of ['handlePaymentIntentFailed', 'handlePaymentIntentRequiresAction']) { const h = await harness(); - await h.controller[method]({ id: 'pi_request_1', last_payment_error: { message: 'Declined' } }); + await h.controller[method](knownPaymentIntent({ + last_payment_error: { message: 'Declined' }, + })); expect(h.state.payments[0]!.status).toBe('failed'); expect(h.state.consequences[0]!.kind).toMatch(/^payment_failed:/); await h.controller.handlePaymentIntentSucceeded(knownPaymentIntent()); diff --git a/packages/database/src/booking-request-migration-safety.spec.ts b/packages/database/src/booking-request-migration-safety.spec.ts index d5bb001f..4e634cbf 100644 --- a/packages/database/src/booking-request-migration-safety.spec.ts +++ b/packages/database/src/booking-request-migration-safety.spec.ts @@ -82,13 +82,18 @@ describe('booking request payment integrity migration safety', () => { expect(source).toContain('booking_request_net_allocation_repair'); expect(source).toContain('task7-net-allocation-v1:'); expect(source).toContain('task7-installment-derived-v1:'); - expect(source).toContain('audit_logs_booking_request_allocation_repair_unique'); - expect(source).toContain('audit_logs_booking_request_installment_repair_unique'); + expect(source).toMatch(/DROP INDEX IF EXISTS audit_logs_booking_request_allocation_repair_unique/i); + expect(source).toMatch(/DROP INDEX IF EXISTS audit_logs_booking_request_installment_repair_unique/i); + expect(source).not.toMatch(/CREATE UNIQUE INDEX IF NOT EXISTS audit_logs_booking_request_(allocation|installment)_repair_unique/i); expect(source).toContain('booking_request_financial_repair_lock'); expect(source).toMatch(/ORDER BY parent\.property_id, parent\.booking_request_id, parent\.id[\s\S]*FOR UPDATE/i); expect(source).toMatch(/ORDER BY allocation\.property_id, allocation\.booking_request_id,[\s\S]*FOR UPDATE/i); expect(source).toMatch(/ORDER BY installment\.property_id, installment\.booking_request_id, installment\.id[\s\S]*FOR UPDATE/i); expect(source).toMatch(/RETURNING[\s\S]*old_amount[\s\S]*new_amount/i); + const repairBlock = source.match( + /booking_request_net_allocation_repair[\s\S]*?\$booking_request_financial_repair_lock\$/, + )?.[0]; + expect(repairBlock).not.toContain('ON CONFLICT'); expect(source).toMatch(/INSERT INTO audit_logs/i); expect(source).toMatch(/DELETE FROM booking_request_payment_allocations/i); expect(source).toMatch(/UPDATE booking_request_payment_allocations/i); diff --git a/packages/database/src/migrations/0024_booking_request_financial_recovery.sql b/packages/database/src/migrations/0024_booking_request_financial_recovery.sql index 7354424f..4bf5c743 100644 --- a/packages/database/src/migrations/0024_booking_request_financial_recovery.sql +++ b/packages/database/src/migrations/0024_booking_request_financial_recovery.sql @@ -56,14 +56,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS br_payment_resolutions_property_provider_tx_un ON booking_request_payment_resolutions (property_id, provider_transaction_id); CREATE UNIQUE INDEX IF NOT EXISTS payments_property_request_parent_id_unique ON payments (property_id, booking_request_id, original_payment_id, id); -CREATE UNIQUE INDEX IF NOT EXISTS audit_logs_booking_request_allocation_repair_unique - ON audit_logs (entity_type, entity_id, ((new_value ->> 'repairKey'))) - WHERE entity_type = 'booking_request_payment_allocation' - AND new_value ? 'repairKey'; -CREATE UNIQUE INDEX IF NOT EXISTS audit_logs_booking_request_installment_repair_unique - ON audit_logs (entity_type, entity_id, ((new_value ->> 'repairKey'))) - WHERE entity_type = 'booking_request_installment' - AND new_value ? 'repairKey'; +DROP INDEX IF EXISTS audit_logs_booking_request_allocation_repair_unique; +DROP INDEX IF EXISTS audit_logs_booking_request_installment_repair_unique; -- booking_request_net_allocation_repair: releases allocations made stale by -- completed refund/return movements from the pre-reconciliation release. Rows @@ -175,7 +169,6 @@ BEGIN ), 'System repaired Booking Request payment allocation to net captured capacity' FROM mutations mutation - ON CONFLICT DO NOTHING RETURNING entity_id ) SELECT COUNT(*) INTO repaired_count FROM audit_evidence; @@ -244,7 +237,6 @@ BEGIN ), 'System repaired Booking Request installment derived payment state' FROM updated repaired - ON CONFLICT DO NOTHING RETURNING entity_id ) SELECT COUNT(*) INTO repaired_count FROM audit_evidence; diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index fdb296cd..4180a230 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -1626,12 +1626,8 @@ async function main() { `CREATE UNIQUE INDEX IF NOT EXISTS booking_request_installments_property_request_id_unique ON booking_request_installments (property_id, booking_request_id, id)`, `CREATE UNIQUE INDEX IF NOT EXISTS payments_property_request_id_unique ON payments (property_id, booking_request_id, id)`, `CREATE UNIQUE INDEX IF NOT EXISTS payments_property_request_parent_id_unique ON payments (property_id, booking_request_id, original_payment_id, id)`, - `CREATE UNIQUE INDEX IF NOT EXISTS audit_logs_booking_request_allocation_repair_unique - ON audit_logs (entity_type, entity_id, ((new_value ->> 'repairKey'))) - WHERE entity_type = 'booking_request_payment_allocation' AND new_value ? 'repairKey'`, - `CREATE UNIQUE INDEX IF NOT EXISTS audit_logs_booking_request_installment_repair_unique - ON audit_logs (entity_type, entity_id, ((new_value ->> 'repairKey'))) - WHERE entity_type = 'booking_request_installment' AND new_value ? 'repairKey'`, + `DROP INDEX IF EXISTS audit_logs_booking_request_allocation_repair_unique`, + `DROP INDEX IF EXISTS audit_logs_booking_request_installment_repair_unique`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_installments_amount_kind_check') THEN ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_amount_kind_check @@ -1842,7 +1838,6 @@ async function main() { ), 'System repaired Booking Request payment allocation to net captured capacity' FROM mutations mutation - ON CONFLICT DO NOTHING RETURNING entity_id ) SELECT COUNT(*) INTO repaired_count FROM audit_evidence; @@ -1909,7 +1904,6 @@ async function main() { ), 'System repaired Booking Request installment derived payment state' FROM updated repaired - ON CONFLICT DO NOTHING RETURNING entity_id ) SELECT COUNT(*) INTO repaired_count FROM audit_evidence; From 30aff70e996059e4dfd4c01bc6ff961d271c1dec Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 25 Aug 2026 00:25:54 +0200 Subject: [PATCH 28/87] feat(booking-requests): send audited request emails --- ...king-request-consequence-worker.service.ts | 11 + .../booking-request-decision.spec.ts | 44 +++ .../booking-request-email.templates.ts | 118 +++++++ .../booking-request-mailer.service.ts | 322 ++++++++++++++++++ .../booking-request-mailer.spec.ts | 285 ++++++++++++++++ .../booking-request-payment-consequence.ts | 102 +++++- .../booking-request-payment.service.ts | 41 ++- .../booking-request-payment.spec.ts | 50 +++ .../booking-request-submission.spec.ts | 45 +++ .../booking-request.controller.ts | 26 ++ .../booking-request/booking-request.module.ts | 6 +- .../booking-request.service.ts | 180 +++++++++- .../payment/stripe-webhook.controller.ts | 13 +- .../modules/payment/stripe-webhook.spec.ts | 26 ++ .../booking-request-migration-safety.spec.ts | 24 ++ .../src/booking-request-schema.spec.ts | 10 + .../0025_booking_request_email_recovery.sql | 29 ++ packages/database/src/push-schema.ts | 12 + .../database/src/schema/booking-request.ts | 12 +- packages/shared/src/index.ts | 44 ++- 20 files changed, 1376 insertions(+), 24 deletions(-) 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 packages/database/src/migrations/0025_booking_request_email_recovery.sql 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 index c42b8c35..3f0eccfa 100644 --- 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 @@ -5,6 +5,7 @@ import { } 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; @@ -21,6 +22,8 @@ implements OnModuleInit, OnModuleDestroy { constructor( @Inject(BookingRequestService) private readonly bookingRequests: BookingRequestService, + @Inject(BookingRequestMailerService) + private readonly mailer: BookingRequestMailerService, ) {} onModuleInit(): void { @@ -44,6 +47,14 @@ implements OnModuleInit, OnModuleDestroy { '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-decision.spec.ts b/apps/api/src/modules/booking-request/booking-request-decision.spec.ts index e911548a..c2f5d0d3 100644 --- a/apps/api/src/modules/booking-request/booking-request-decision.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-decision.spec.ts @@ -389,6 +389,17 @@ function makeHarness(requests: RequestRow[] = [pendingRequest()]) { })), 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, @@ -402,6 +413,7 @@ function makeHarness(requests: RequestRow[] = [pendingRequest()]) { reservation, folio, ancillary, + mailer, ) as BookingRequestService & Record Promise>; return { @@ -415,6 +427,9 @@ function makeHarness(requests: RequestRow[] = [pendingRequest()]) { reservation, folio, ancillary, + mailer, + emailQueueTransactionStates, + emailDeliveryTransactionStates, setAvailability(value: boolean) { hasAvailability = value; }, @@ -799,6 +814,13 @@ describe('BookingRequestService acceptance', () => { }); 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 () => { @@ -1132,6 +1154,28 @@ describe('BookingRequestService denial', () => { })); 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 () => { 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..867fdf50 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-mailer.service.ts @@ -0,0 +1,322 @@ +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + auditLogs, + bookingRequestEmailDeliveries, + bookingRequests, +} from '@telivityhaip/database'; +import { and, asc, eq, isNull, lte, or } from 'drizzle-orm'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { DRIZZLE } from '../../database/database.module'; +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; +}; + +type Delivery = typeof bookingRequestEmailDeliveries.$inferSelect; +type MailerDatabase = PostgresJsDatabase; +type QueueExecutor = Pick; + +const CLAIM_LEASE_MS = 5 * 60 * 1000; + +@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 [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, + }) + .onConflictDoNothing() + .returning({ id: bookingRequestEmailDeliveries.id }); + + if (created) { + await executor.insert(auditLogs).values({ + propertyId: input.propertyId, + 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); + } + + async deliver( + deliveryId: string, + bookingRequestId: string, + propertyId: string, + ): Promise { + const claimed = await this.claim(deliveryId, bookingRequestId, propertyId); + if (!claimed || claimed.status === 'sent') return claimed; + + let sent: boolean; + try { + const result = await this.emailService.send({ + to: claimed.recipient, + subject: claimed.subject, + text: claimed.bodyText, + html: this.textAsHtml(claimed.bodyText), + }); + sent = result.sent; + } catch { + sent = false; + } + + const finishedAt = new Date(); + const errorMessage = sent ? null : 'Email transport failed'; + const [updated] = await this.db + .update(bookingRequestEmailDeliveries) + .set({ + status: sent ? 'sent' : 'failed', + claimedAt: null, + errorMessage, + sentAt: sent ? finishedAt : null, + updatedAt: finishedAt, + }) + .where(and( + eq(bookingRequestEmailDeliveries.id, claimed.id), + eq(bookingRequestEmailDeliveries.propertyId, propertyId), + eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), + eq(bookingRequestEmailDeliveries.claimedAt, claimed.claimedAt!), + )) + .returning(); + const result = updated ?? { ...claimed, status: sent ? 'sent' : 'failed', errorMessage }; + await this.auditAttemptBestEffort(result); + return result; + } + + async retry( + deliveryId: string, + bookingRequestId: string, + propertyId: string, + ): Promise { + await this.findDelivery(deliveryId, bookingRequestId, propertyId); + return this.deliver(deliveryId, bookingRequestId, propertyId); + } + + async deliverForRequestBestEffort( + bookingRequestId: string, + propertyId: string, + ): Promise { + try { + const deliveries = await this.listForRequest(bookingRequestId, propertyId); + for (const delivery of deliveries) { + if (delivery.status === 'sent') 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 staleBefore = new Date(Date.now() - CLAIM_LEASE_MS); + const rows = await this.db + .select() + .from(bookingRequestEmailDeliveries) + .where(and( + or( + eq(bookingRequestEmailDeliveries.status, 'pending'), + eq(bookingRequestEmailDeliveries.status, 'failed'), + ), + or( + isNull(bookingRequestEmailDeliveries.claimedAt), + lte(bookingRequestEmailDeliveries.claimedAt, staleBefore), + ), + )) + .orderBy(asc(bookingRequestEmailDeliveries.createdAt)) + .limit(Math.max(1, Math.min(limit, 500))); + const recoverable = rows.filter((row: Delivery) => + row.status !== 'sent' + && (!row.claimedAt || row.claimedAt.getTime() <= staleBefore.getTime())); + for (const row of recoverable) { + await this.deliver(row.id, row.bookingRequestId, row.propertyId); + } + return recoverable.length; + } + + private async claim( + deliveryId: string, + bookingRequestId: string, + propertyId: string, + ): Promise { + return this.db.transaction(async (tx) => { + const rows = await tx + .select() + .from(bookingRequestEmailDeliveries) + .where(and( + eq(bookingRequestEmailDeliveries.id, deliveryId), + eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), + eq(bookingRequestEmailDeliveries.propertyId, propertyId), + )) + .for('update'); + 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`); + if (row.status === 'sent') return row; + if (row.claimedAt && row.claimedAt.getTime() > Date.now() - CLAIM_LEASE_MS) { + return undefined; + } + const attemptedAt = new Date(); + const [claimed] = await tx + .update(bookingRequestEmailDeliveries) + .set({ + status: 'pending', + attempts: row.attempts + 1, + claimedAt: attemptedAt, + lastAttemptAt: attemptedAt, + errorMessage: null, + updatedAt: attemptedAt, + }) + .where(and( + eq(bookingRequestEmailDeliveries.id, deliveryId), + eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), + eq(bookingRequestEmailDeliveries.propertyId, propertyId), + )) + .returning(); + return claimed; + }); + } + + 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( + deliveryId: string, + bookingRequestId: string, + propertyId: string, + ): Promise { + const rows = await this.db + .select() + .from(bookingRequestEmailDeliveries) + .where(and( + eq(bookingRequestEmailDeliveries.id, deliveryId), + eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), + eq(bookingRequestEmailDeliveries.propertyId, propertyId), + )); + 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 async auditAttemptBestEffort(delivery: Delivery): Promise { + try { + await this.db.insert(auditLogs).values({ + propertyId: delivery.propertyId, + action: 'update', + entityType: 'booking_request_email_delivery', + entityId: delivery.id, + description: delivery.status === 'sent' + ? 'Booking request email delivered' + : 'Booking request email delivery failed', + newValue: { + bookingRequestId: delivery.bookingRequestId, + kind: delivery.kind, + status: delivery.status, + attempts: delivery.attempts, + ...(delivery.errorMessage ? { error: delivery.errorMessage } : {}), + }, + }); + } catch (error: unknown) { + this.logger.error( + `Email delivery ${delivery.id} state changed but its audit write failed`, + error instanceof Error ? error.stack : undefined, + ); + } + } + + 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..93eaa4a4 --- /dev/null +++ b/apps/api/src/modules/booking-request/booking-request-mailer.spec.ts @@ -0,0 +1,285 @@ +import { NotFoundException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { + auditLogs, + bookingRequestEmailDeliveries, + bookingRequests, +} from '@telivityhaip/database'; +import { WEBHOOK_EVENTS } 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 { + 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, + claimedAt: null, + lastAttemptAt: null, + sentAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +function createHarness(seed: Delivery[] = []) { + const state = { + requests: [{ id: REQUEST_ID, propertyId: PROPERTY_ID }], + deliveries: seed.map((row) => ({ ...row })), + audits: [] as Array>, + }; + + const insert = vi.fn((table: unknown) => ({ + values: (values: Record) => { + if (table === auditLogs) { + 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 []; + 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) { + Object.assign(current, changes); + } + return Promise.resolve(undefined).then(resolve); + }, + }), + }), + })); + + const db: any = { + insert, + select, + update, + transaction: (work: (tx: any) => unknown) => work(db), + }; + 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()); + + 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: 'pending', attempts: 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 }); + expect(h.emailService.send).toHaveBeenCalledOnce(); + }); + + it('records a safe failed result without throwing and retries it durably', async () => { + const h = createHarness([delivery()]); + h.emailService.send + .mockRejectedValueOnce(new Error('smtp password secret-token and pm_123 leaked')) + .mockResolvedValueOnce({ sent: true, provider: 'smtp' }); + + const failed = await h.service.deliver(DELIVERY_ID, REQUEST_ID, PROPERTY_ID); + expect(failed).toMatchObject({ + status: 'failed', + attempts: 1, + errorMessage: 'Email transport failed', + }); + expect(failed?.errorMessage).not.toMatch(/secret|pm_123|password/i); + + const sent = await h.service.retry(DELIVERY_ID, REQUEST_ID, PROPERTY_ID); + expect(sent).toMatchObject({ status: 'sent', attempts: 2, errorMessage: null }); + expect(h.emailService.send).toHaveBeenCalledTimes(2); + }); + + 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]).toMatchObject({ propertyId: PROPERTY_ID, bookingRequestId: REQUEST_ID }); + }); + + it('recovers pending and failed deliveries while never redelivering sent mail', async () => { + const h = createHarness([ + delivery(), + delivery({ id: 'cccccccc-0000-4000-a000-000000000002', status: 'failed' }), + delivery({ id: 'cccccccc-0000-4000-a000-000000000003', status: 'sent' }), + ]); + 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.every((row) => row.status === 'sent')).toBe(true); + }); +}); + +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('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-payment-consequence.ts b/apps/api/src/modules/booking-request/booking-request-payment-consequence.ts index ac1da857..c734ca12 100644 --- a/apps/api/src/modules/booking-request/booking-request-payment-consequence.ts +++ b/apps/api/src/modules/booking-request/booking-request-payment-consequence.ts @@ -1,4 +1,16 @@ -import { bookingRequestConsequences } from '@telivityhaip/database'; +import { + bookingRequestConsequences, + bookingRequestEmailDeliveries, + bookingRequests, +} from '@telivityhaip/database'; +import type { BookingRequestConsequenceKind } from '@telivityhaip/database'; +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 = | 'payment.received' @@ -14,6 +26,7 @@ const kindPrefix: Record = { 'payment.external_returned': 'external_returned', 'payment.retained': 'payment_retained', }; +type FinancialConsequenceExecutor = Pick; /** * Atomically persists a replayable financial webhook consequence. @@ -21,7 +34,7 @@ const kindPrefix: Record = { * or Stripe delivery replay repairs a missing outbox row without duplicating it. */ export async function ensureBookingRequestFinancialConsequence( - tx: any, + tx: FinancialConsequenceExecutor, input: { event: BookingRequestFinancialEvent; logicalId: string; @@ -33,7 +46,10 @@ export async function ensureBookingRequestFinancialConsequence( }, ): Promise { const compactLogicalId = input.logicalId.replaceAll('-', ''); - const kind = `${kindPrefix[input.event]}:${compactLogicalId}`.slice(0, 50); + const kind = `${kindPrefix[input.event]}:${compactLogicalId}`.slice( + 0, + 50, + ) as BookingRequestConsequenceKind; const payload = { event: input.event, entityType: input.entityType, @@ -53,4 +69,84 @@ export async function ensureBookingRequestFinancialConsequence( attempts: 0, }) .onConflictDoNothing(); + await ensureFinancialEmail(tx, input); +} + +async function ensureFinancialEmail( + tx: FinancialConsequenceExecutor, + input: Parameters[1], +): Promise { + if (input.event === 'payment.retained') return; + 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' + || input.event === 'payment.external_returned' + ) { + kind = 'refund'; + logicalPrefix = 'refund'; + content = refundedBookingRequestPaymentEmail({ + guestFirstName: request.guestFirstName, + amount, + currencyCode, + source: input.event === 'payment.external_returned' ? 'external_return' : 'refund', + }); + } else { + kind = 'failure'; + logicalPrefix = 'failure'; + content = failedBookingRequestPaymentEmail({ + guestFirstName: request.guestFirstName, + amount, + currencyCode, + operation: input.data['type'] === 'refund' ? 'refund' : 'charge', + }); + } + + 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, + }) + .onConflictDoNothing(); +} + +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.service.ts b/apps/api/src/modules/booking-request/booking-request-payment.service.ts index 939dc4ea..ea8bed2a 100644 --- a/apps/api/src/modules/booking-request/booking-request-payment.service.ts +++ b/apps/api/src/modules/booking-request/booking-request-payment.service.ts @@ -33,6 +33,7 @@ import { import { remainingCapturedAmount } from '../payment/payment-ledger'; import { reconcileBookingRequestPaymentAllocations } from './booking-request-allocation-reconciler'; import { ensureBookingRequestFinancialConsequence } from './booking-request-payment-consequence'; +import { BookingRequestMailerService } from './booking-request-mailer.service'; import { assertAllocationAmount, resolveInstallmentAmount } from './booking-request-money'; import type { AllocateBookingRequestPaymentDto, @@ -70,6 +71,8 @@ export class BookingRequestPaymentService { 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) { @@ -514,6 +517,7 @@ export class BookingRequestPaymentService { }); if (!prepared.isNew && prepared.payment.status !== 'pending') { + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); return this.paymentResponse(prepared.payment); } @@ -674,6 +678,7 @@ export class BookingRequestPaymentService { } return updated; }); + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); return this.paymentResponse(finalized); } @@ -750,7 +755,7 @@ export class BookingRequestPaymentService { status: existing.status, amount: existing.amount, currencyCode: existing.currencyCode, - externalReference: reference, + source: 'external', }, }); return { payment: existing, isNew: false }; @@ -786,7 +791,7 @@ export class BookingRequestPaymentService { status: created.status, amount: created.amount, currencyCode: created.currencyCode, - externalReference: reference, + source: 'external', }, }); if (created.folioId) { @@ -794,6 +799,7 @@ export class BookingRequestPaymentService { } return { payment: created, isNew: true }; }); + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); return this.paymentResponse(result.payment); } @@ -863,6 +869,7 @@ export class BookingRequestPaymentService { folioId: movement.folioId, originalPaymentId: original.id, refundAmount: amount.toFixed(2), + currencyCode: original.currencyCode, resolutionId: replay.id, }, }); @@ -909,6 +916,7 @@ export class BookingRequestPaymentService { 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), @@ -977,6 +985,7 @@ export class BookingRequestPaymentService { providerStatus, actor, }); + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); throw new ConflictException(`Refund failed: ${gatewayResult.errorMessage ?? 'Gateway declined'}`); } @@ -989,6 +998,7 @@ export class BookingRequestPaymentService { gatewayResult, actor, }); + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); return { ...finalized, resolution: this.resolutionResponse(finalized.resolution), @@ -1068,7 +1078,8 @@ export class BookingRequestPaymentService { originalPaymentId: original.id, returnAmount: amount.toFixed(2), resolutionId: resolution.id, - externalReference: reference, + currencyCode: original.currencyCode, + source: 'external_return', }, }); return { movement: existing, resolution, isNew: false }; @@ -1138,7 +1149,8 @@ export class BookingRequestPaymentService { originalPaymentId: original.id, returnAmount: amount.toFixed(2), resolutionId: resolution.id, - externalReference: reference, + currencyCode: original.currencyCode, + source: 'external_return', }, }); await this.reconcileAllocationsForPayment( @@ -1153,6 +1165,7 @@ export class BookingRequestPaymentService { } return { movement, resolution, isNew: true }; }); + await this.deliverEmailsBestEffort(bookingRequestId, propertyId); return { movement: this.paymentResponse(result.movement), resolution: this.resolutionResponse(result.resolution), @@ -1372,7 +1385,7 @@ export class BookingRequestPaymentService { true, ); this.assertNotDenied(request); - await this.findParentPayment( + const parent = await this.findParentPayment( tx, input.bookingRequestId, input.paymentId, @@ -1426,6 +1439,8 @@ export class BookingRequestPaymentService { data: { paymentId: input.paymentId, type: 'refund', + amount: claim.amount, + currencyCode: parent.currencyCode, providerStatus: input.providerStatus ?? 'failed', }, }); @@ -1487,8 +1502,8 @@ export class BookingRequestPaymentService { folioId: movement.folioId, originalPaymentId: original.id, refundAmount: claim.amount, + currencyCode: original.currencyCode, resolutionId: claim.id, - providerRefundId: claim.providerTransactionId, }, }); return { movement: this.paymentResponse(movement), resolution: claim }; @@ -1586,8 +1601,8 @@ export class BookingRequestPaymentService { folioId: movement.folioId, originalPaymentId: original.id, refundAmount: claim.amount, + currencyCode: original.currencyCode, resolutionId: claim.id, - providerRefundId: input.gatewayResult.transactionId, }, }); await this.reconcileAllocationsForPayment( @@ -1604,6 +1619,18 @@ export class BookingRequestPaymentService { }); } + 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, 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 index 8f539b39..4e208ebc 100644 --- a/apps/api/src/modules/booking-request/booking-request-payment.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-payment.spec.ts @@ -5,6 +5,7 @@ import { validate } from 'class-validator'; import { auditLogs, bookingRequestConsequences, + bookingRequestEmailDeliveries, bookingRequestInstallments, bookingRequestPaymentAllocations, bookingRequestPaymentResolutions, @@ -40,6 +41,7 @@ type State = { resolutions: Array>; audits: Array>; consequences: Array>; + emails: Array>; }; function request(overrides: Record = {}) { @@ -55,6 +57,8 @@ function request(overrides: Record = {}) { stripePaymentMethodId: 'pm_saved', cardLastFour: '4242', cardBrand: 'visa', + guestFirstName: 'Ada', + guestEmail: 'ada@example.com', ...overrides, }; } @@ -109,6 +113,7 @@ function tableRows(state: State, table: unknown): Array> { 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'); } @@ -173,6 +178,16 @@ function makeDatabase(state: State) { 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')}`, @@ -256,6 +271,7 @@ function makeHarness(overrides: Partial = {}) { resolutions: [], audits: [], consequences: [], + emails: [], ...structuredClone(overrides), }; const database = makeDatabase(state); @@ -279,11 +295,15 @@ function makeHarness(overrides: Partial = {}) { 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, @@ -292,6 +312,7 @@ function makeHarness(overrides: Partial = {}) { gateway, folioService, refundGateway, + mailer, gatewayTransactionStates, }; } @@ -636,6 +657,21 @@ describe('BookingRequestPaymentService saved-card charges', () => { 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', + }), + ]); + 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 () => { @@ -899,6 +935,9 @@ describe('BookingRequestPaymentService saved-card charges', () => { 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:/) }), + ]); } }); @@ -1049,6 +1088,9 @@ describe('BookingRequestPaymentService external movements and denial resolutions 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, @@ -1209,6 +1251,9 @@ describe('BookingRequestPaymentService external movements and denial resolutions 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 () => { @@ -1710,6 +1755,10 @@ describe('BookingRequestPaymentService external movements and denial resolutions expect(harness.state.consequences).toEqual([ expect.objectContaining({ kind: expect.stringMatching(/^external_returned:/) }), ]); + 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 () => { @@ -1888,6 +1937,7 @@ describe('BookingRequestPaymentService external movements and denial resolutions reason: 'Non-refundable supplier cost', resolvedBy: actor.userId, }); + expect(harness.state.emails).toHaveLength(0); }); it('allows retention only while the request decision is pending', async () => { 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 index 2feb360a..b391c240 100644 --- a/apps/api/src/modules/booking-request/booking-request-submission.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-submission.spec.ts @@ -93,6 +93,7 @@ const submitDto = { } as SubmitBookingRequestDto; function makeHarness() { + let transactionActive = false; let insertedValues: Record | undefined; const storedRequests: Array<{ id: string; @@ -240,9 +241,11 @@ function makeHarness() { release = resolve; }); await previous; + transactionActive = true; try { return await callback(db); } finally { + transactionActive = false; release(); } }); @@ -279,6 +282,15 @@ function makeHarness() { 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], @@ -287,6 +299,11 @@ function makeHarness() { 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 { @@ -298,6 +315,7 @@ function makeHarness() { bookingEngine, savedPaymentMethod, webhook, + mailer, values, consequenceValues, auditValues, @@ -740,6 +758,33 @@ describe('BookingRequestService.submit', () => { 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 () => { diff --git a/apps/api/src/modules/booking-request/booking-request.controller.ts b/apps/api/src/modules/booking-request/booking-request.controller.ts index b46d0094..db5d561b 100644 --- a/apps/api/src/modules/booking-request/booking-request.controller.ts +++ b/apps/api/src/modules/booking-request/booking-request.controller.ts @@ -17,6 +17,7 @@ import { } 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 @@ -44,6 +45,8 @@ export class BookingRequestController { @Inject(BookingRequestService) private readonly service: BookingRequestService, @Inject(BookingRequestPaymentService) private readonly paymentService: BookingRequestPaymentService, + @Inject(BookingRequestMailerService) + private readonly mailer: BookingRequestMailerService, ) {} @Get() @@ -64,6 +67,29 @@ export class BookingRequestController { return this.service.findById(id, propertyId); } + @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, + ) { + return this.mailer.retry(deliveryId, id, propertyId); + } + @Post(':id/accept') @RequirePermissions('reservations.write') @ApiQuery({ name: 'propertyId', required: true }) diff --git a/apps/api/src/modules/booking-request/booking-request.module.ts b/apps/api/src/modules/booking-request/booking-request.module.ts index 589f4146..79685d5c 100644 --- a/apps/api/src/modules/booking-request/booking-request.module.ts +++ b/apps/api/src/modules/booking-request/booking-request.module.ts @@ -10,11 +10,13 @@ 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'; @Module({ imports: [ @@ -26,16 +28,18 @@ import { BookingRequestPaymentService } from './booking-request-payment.service' GuestModule, FolioModule, AncillaryModule, + EmailModule, ], controllers: [BookingRequestPublicController, BookingRequestController], providers: [ BookingRequestService, BookingRequestPaymentService, + BookingRequestMailerService, BookingRequestConsequenceWorkerService, BookingKeyGuard, BookingEngineScopeGuard, BookingThrottleGuard, ], - exports: [BookingRequestService, BookingRequestPaymentService], + exports: [BookingRequestService, BookingRequestPaymentService, BookingRequestMailerService], }) 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 index 2c3c258e..c66cd3c5 100644 --- a/apps/api/src/modules/booking-request/booking-request.service.ts +++ b/apps/api/src/modules/booking-request/booking-request.service.ts @@ -35,6 +35,11 @@ import { sql, } from 'drizzle-orm'; import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import type { + BookingRequestAcceptedWebhook, + BookingRequestCreatedWebhook, + BookingRequestDeniedWebhook, +} from '@telivityhaip/shared'; import { actorFields, type AuditActor, @@ -69,6 +74,12 @@ import { } from './booking-request-money'; import { assertBookingRequestTransition } from './booking-request-state'; import { buildAcceptedPricingSnapshot } from './booking-request-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'; @@ -121,6 +132,7 @@ type ExistingRequest = { 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.'; @@ -153,6 +165,8 @@ export class BookingRequestService { 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) { @@ -267,7 +281,9 @@ export class BookingRequestService { 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') { @@ -407,7 +423,7 @@ export class BookingRequestService { throw new ConflictException('Booking request decision changed concurrently'); } - await this.insertConsequence(tx, propertyId, id, ACCEPTED_CONSEQUENCE_KIND, { + const acceptedEvent = { event: 'booking_request.accepted', entityType: 'booking_request', entityId: id, @@ -420,7 +436,14 @@ export class BookingRequestService { acceptedTotal: pricing.grandTotal, }, timestamp: decidedAt.toISOString(), - }); + } satisfies BookingRequestAcceptedWebhook; + await this.insertConsequence( + tx, + propertyId, + id, + ACCEPTED_CONSEQUENCE_KIND, + acceptedEvent, + ); await this.insertConsequence( tx, propertyId, @@ -484,10 +507,12 @@ export class BookingRequestService { }, 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); } @@ -503,7 +528,7 @@ export class BookingRequestService { const denied = await this.db.transaction(async (tx) => { const locked = await this.lockRequest(tx, id, propertyId); if (locked.status === 'denied') { - return locked; + return { request: locked, replay: true }; } assertBookingRequestTransition(locked.status, 'denied'); @@ -570,14 +595,21 @@ export class BookingRequestService { if (!updated) { throw new ConflictException('Booking request decision changed concurrently'); } - await this.insertConsequence(tx, propertyId, id, DENIED_CONSEQUENCE_KIND, { + 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, action: 'update', @@ -588,11 +620,16 @@ export class BookingRequestService { newValue: { status: 'denied', denialReason: reason }, description: 'Booking request denied', }); - return updated; + await this.queueDeniedEmail(updated, tx); + return { request: updated, replay: false }; }); + if (denied.replay) { + await this.queueDeniedEmailBestEffort(denied.request, propertyId); + } await this.deliverConsequencesBestEffort(id, propertyId); - return toDeniedBookingRequestDecision(denied); + await this.deliverEmailsBestEffort(id, propertyId); + return toDeniedBookingRequestDecision(denied.request); } async createPaymentMethodSetup( @@ -635,6 +672,7 @@ export class BookingRequestService { if (existing) { const acknowledgement = this.acknowledgeReplay(existing, fingerprint); await this.deliverCreatedConsequenceBestEffort(existing.id, propertyId); + await this.deliverEmailsBestEffort(existing.id, propertyId); return acknowledgement; } @@ -742,6 +780,14 @@ export class BookingRequestService { 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 }; } @@ -758,6 +804,7 @@ export class BookingRequestService { }); await this.deliverCreatedConsequenceBestEffort(result.requestId, propertyId); + await this.deliverEmailsBestEffort(result.requestId, propertyId); return this.acknowledgement(result.requestId); } @@ -1103,7 +1150,7 @@ export class BookingRequestService { private createdEventPayload( requestId: string, propertyId: string, - ): WebhookPayload { + ): BookingRequestCreatedWebhook { return { event: 'booking_request.created', entityType: 'booking_request', @@ -1114,6 +1161,123 @@ export class BookingRequestService { }; } + 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, diff --git a/apps/api/src/modules/payment/stripe-webhook.controller.ts b/apps/api/src/modules/payment/stripe-webhook.controller.ts index 5020a9d1..47efb721 100644 --- a/apps/api/src/modules/payment/stripe-webhook.controller.ts +++ b/apps/api/src/modules/payment/stripe-webhook.controller.ts @@ -382,7 +382,8 @@ export class StripeWebhookController { data: { folioId, status: current.status, - stripePaymentIntentId: pi.id, + amount: current.amount, + currencyCode: current.currencyCode, }, }); } @@ -615,7 +616,13 @@ export class StripeWebhookController { bookingRequestId: claim.bookingRequestId, entityType: 'booking_request_payment_resolution', entityId: claim.id, - data: { paymentId: parent.id, type: 'refund', providerStatus, stripeRefundId: refund.id }, + data: { + paymentId: parent.id, + type: 'refund', + amount: claim.amount, + currencyCode: parent.currencyCode, + providerStatus, + }, }); return { blocked: false }; } @@ -708,7 +715,7 @@ export class StripeWebhookController { folioId: request.acceptedFolioId, originalPaymentId: parent.id, refundAmount: amount.toFixed(2), - stripeRefundId: refund.id, + currencyCode: parent.currencyCode, resolutionId: claim.id, }, }); diff --git a/apps/api/src/modules/payment/stripe-webhook.spec.ts b/apps/api/src/modules/payment/stripe-webhook.spec.ts index 7fe9bb34..819d41c2 100644 --- a/apps/api/src/modules/payment/stripe-webhook.spec.ts +++ b/apps/api/src/modules/payment/stripe-webhook.spec.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import { auditLogs, bookingRequestConsequences, + bookingRequestEmailDeliveries, bookingRequestPaymentResolutions, bookingRequests, payments, @@ -30,6 +31,7 @@ type State = { resolutions: any[]; consequences: any[]; audits: any[]; + emails: any[]; }; function request(overrides: Record = {}) { @@ -41,6 +43,8 @@ function request(overrides: Record = {}) { currencyCode: 'USD', stripeCustomerId: 'cus_saved', stripePaymentMethodId: 'pm_saved', + guestFirstName: 'Ada', + guestEmail: 'ada@example.com', ...overrides, }; } @@ -119,6 +123,7 @@ function rowsFor(state: State, table: unknown): any[] { if (table === bookingRequestPaymentResolutions) return state.resolutions; if (table === bookingRequestConsequences) return state.consequences; if (table === auditLogs) return state.audits; + if (table === bookingRequestEmailDeliveries) return state.emails; throw new Error('Unexpected table in Stripe webhook test'); } @@ -186,6 +191,12 @@ function makeDb(state: State) { && row.kind === input['kind'])) { return []; } + if (table === bookingRequestEmailDeliveries && rows.some((row) => + row.propertyId === input['propertyId'] + && row.bookingRequestId === input['bookingRequestId'] + && row.logicalKey === input['logicalKey'])) { + return []; + } if (table === payments && input['idempotencyKey'] && rows.some((row) => row.propertyId === input['propertyId'] && row.idempotencyKey === input['idempotencyKey'])) { if (!ignoreConflict) throw new Error('duplicate payment idempotency'); @@ -252,6 +263,7 @@ async function harness(overrides: Partial = {}) { resolutions: [], consequences: [], audits: [], + emails: [], ...structuredClone(overrides), }; const db = makeDb(state); @@ -309,6 +321,10 @@ describe('StripeWebhookController financial finalization', () => { expect(h.state.consequences).toEqual([ expect.objectContaining({ kind: expect.stringMatching(/^payment_received:/), status: 'pending' }), ]); + expect(h.state.emails).toEqual([ + expect.objectContaining({ kind: 'payment', logicalKey: expect.stringMatching(/^payment:/) }), + ]); + expect(JSON.stringify(h.state.emails)).not.toMatch(/pi_request|pm_saved|cus_saved|https?:\/\//i); expect(h.folioService.recalculateBalance).toHaveBeenCalledWith(FOLIO_ID, PROPERTY_ID, h.db); expect(h.webhookService.emit).not.toHaveBeenCalled(); }); @@ -341,6 +357,7 @@ describe('StripeWebhookController financial finalization', () => { gatewayTransactionId: 'pi_recovered_from_metadata', }); expect(h.state.consequences).toHaveLength(1); + expect(h.state.emails).toHaveLength(1); const gateway = { charge: vi.fn() }; const service = new (BookingRequestPaymentService as any)( @@ -348,6 +365,7 @@ describe('StripeWebhookController financial finalization', () => { gateway, h.folioService, { refund: vi.fn() }, + { deliverForRequestBestEffort: vi.fn() }, ) as BookingRequestPaymentService; const replay = await service.chargeSavedCard( REQUEST_ID, @@ -576,6 +594,7 @@ describe('StripeWebhookController financial finalization', () => { await h.controller.handlePaymentIntentSucceeded(knownPaymentIntent()); expect(h.state.payments[0]!.folioId).toBe(FOLIO_ID); expect(h.state.consequences).toHaveLength(1); + expect(h.state.emails).toHaveLength(1); expect(h.folioService.recalculateBalance).toHaveBeenCalledTimes(2); }); @@ -605,6 +624,9 @@ describe('StripeWebhookController financial finalization', () => { })); expect(h.state.payments[0]!.status).toBe('failed'); expect(h.state.consequences[0]!.kind).toMatch(/^payment_failed:/); + expect(h.state.emails).toEqual([ + expect.objectContaining({ kind: 'failure', logicalKey: expect.stringMatching(/^failure:/) }), + ]); await h.controller.handlePaymentIntentSucceeded(knownPaymentIntent()); expect(h.state.payments[0]!.status).toBe('failed'); } @@ -625,6 +647,10 @@ describe('StripeWebhookController financial finalization', () => { expect(h.state.payments.filter((row) => row.originalPaymentId === PAYMENT_ID)).toEqual([ expect.objectContaining({ amount: '-25.00', gatewayTransactionId: 're_second' }), ]); + expect(h.state.emails).toEqual([ + expect.objectContaining({ kind: 'refund', logicalKey: expect.stringMatching(/^refund:/) }), + ]); + expect(JSON.stringify(h.state.emails)).not.toContain('re_second'); }); it('handles two 25 refunds out of order and replays without double ledger rows', async () => { diff --git a/packages/database/src/booking-request-migration-safety.spec.ts b/packages/database/src/booking-request-migration-safety.spec.ts index 4e634cbf..9d78b111 100644 --- a/packages/database/src/booking-request-migration-safety.spec.ts +++ b/packages/database/src/booking-request-migration-safety.spec.ts @@ -14,6 +14,10 @@ const financialRecoveryMigration = readFileSync( new URL('./migrations/0024_booking_request_financial_recovery.sql', import.meta.url), 'utf8', ); +const emailRecoveryMigration = readFileSync( + new URL('./migrations/0025_booking_request_email_recovery.sql', import.meta.url), + 'utf8', +); describe('booking request accepted-pricing migration safety', () => { it('fails instead of accepting an already-accepted request without an operational snapshot', () => { @@ -103,3 +107,23 @@ describe('booking request payment integrity migration safety', () => { } }); }); + +describe('booking request email recovery migration safety', () => { + it('adds stable logical identity, claim recovery, and aggregate ownership in both paths', () => { + for (const source of [emailRecoveryMigration, pushSchema]) { + expect(source).toContain('ADD COLUMN IF NOT EXISTS logical_key'); + expect(source).toContain('ADD COLUMN IF NOT EXISTS claimed_at'); + expect(source).toContain('booking_request_email_deliveries_logical_key_unique'); + expect(source).toContain('booking_request_email_deliveries_request_fkey'); + } + }); + + it('backfills existing rows before making logical identity required', () => { + const addColumn = emailRecoveryMigration.indexOf('ADD COLUMN IF NOT EXISTS logical_key'); + const backfill = emailRecoveryMigration.indexOf('task8-legacy:'); + const notNull = emailRecoveryMigration.indexOf('ALTER COLUMN logical_key SET NOT NULL'); + expect(addColumn).toBeGreaterThanOrEqual(0); + expect(backfill).toBeGreaterThan(addColumn); + expect(notNull).toBeGreaterThan(backfill); + }); +}); diff --git a/packages/database/src/booking-request-schema.spec.ts b/packages/database/src/booking-request-schema.spec.ts index 2343c7f3..91b0b217 100644 --- a/packages/database/src/booking-request-schema.spec.ts +++ b/packages/database/src/booking-request-schema.spec.ts @@ -3,6 +3,7 @@ import { getTableConfig } from 'drizzle-orm/pg-core'; import { bookingEngineConfig, bookingRequestConsequences, + bookingRequestEmailDeliveries, bookingRequests, bookingRequestInstallments, bookingRequestPaymentAllocations, @@ -38,6 +39,8 @@ describe('booking request schema', () => { expect(bookingRequestPaymentResolutions.movementId).toBeDefined(); expect(bookingRequestPaymentResolutions.attempts).toBeDefined(); expect(bookingRequestPaymentResolutions.lastError).toBeDefined(); + expect(bookingRequestEmailDeliveries.logicalKey).toBeDefined(); + expect(bookingRequestEmailDeliveries.claimedAt).toBeDefined(); expect(bookingEngineConfig.bookingMode).toBeDefined(); expect(bookingEngineConfig.paymentMethodCollection).toBeDefined(); expect(payments.bookingRequestId).toBeDefined(); @@ -93,6 +96,13 @@ describe('booking request schema', () => { ); expect(getTableConfig(bookingRequestInstallments).foreignKeys.map((key) => key.getName())) .toContain('booking_request_installments_request_fkey'); + const emailConfig = getTableConfig(bookingRequestEmailDeliveries); + expect(emailConfig.indexes.map((index) => index.config.name)).toContain( + 'booking_request_email_deliveries_logical_key_unique', + ); + expect(emailConfig.foreignKeys.map((key) => key.getName())).toContain( + 'booking_request_email_deliveries_request_fkey', + ); expect(getTableConfig(payments).checks.map((check) => check.name)).toEqual( expect.arrayContaining([ 'payments_booking_request_parent_positive_check', diff --git a/packages/database/src/migrations/0025_booking_request_email_recovery.sql b/packages/database/src/migrations/0025_booking_request_email_recovery.sql new file mode 100644 index 00000000..2e51497b --- /dev/null +++ b/packages/database/src/migrations/0025_booking_request_email_recovery.sql @@ -0,0 +1,29 @@ +ALTER TABLE booking_request_email_deliveries + ADD COLUMN IF NOT EXISTS logical_key varchar(200), + ADD COLUMN IF NOT EXISTS claimed_at timestamptz; + +UPDATE booking_request_email_deliveries +SET logical_key = 'task8-legacy:' || id::text +WHERE logical_key IS NULL; + +ALTER TABLE booking_request_email_deliveries + ALTER COLUMN logical_key SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS booking_request_email_deliveries_logical_key_unique + ON booking_request_email_deliveries (property_id, booking_request_id, logical_key); + +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'booking_request_email_deliveries_request_fkey' + ) THEN + ALTER TABLE booking_request_email_deliveries + ADD CONSTRAINT booking_request_email_deliveries_request_fkey + FOREIGN KEY (property_id, booking_request_id) + REFERENCES booking_requests(property_id, id) + NOT VALID; + END IF; +END $$; + +ALTER TABLE booking_request_email_deliveries + VALIDATE CONSTRAINT booking_request_email_deliveries_request_fkey; diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index 4180a230..1c724a58 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -1246,6 +1246,7 @@ async function main() { id uuid PRIMARY KEY DEFAULT gen_random_uuid(), property_id uuid NOT NULL REFERENCES properties(id), booking_request_id uuid NOT NULL REFERENCES booking_requests(id), + logical_key varchar(200) NOT NULL, kind booking_request_email_delivery_kind NOT NULL, status booking_request_email_delivery_status NOT NULL DEFAULT 'pending', recipient varchar(255) NOT NULL, @@ -1253,11 +1254,17 @@ async function main() { body_text text NOT NULL, error_message text, attempts integer NOT NULL DEFAULT 0, + claimed_at timestamptz, last_attempt_at timestamptz, sent_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() )`, + `ALTER TABLE booking_request_email_deliveries ADD COLUMN IF NOT EXISTS logical_key varchar(200)`, + `ALTER TABLE booking_request_email_deliveries ADD COLUMN IF NOT EXISTS claimed_at timestamptz`, + `UPDATE booking_request_email_deliveries SET logical_key = 'task8-legacy:' || id::text WHERE logical_key IS NULL`, + `ALTER TABLE booking_request_email_deliveries ALTER COLUMN logical_key SET NOT NULL`, + `CREATE UNIQUE INDEX IF NOT EXISTS booking_request_email_deliveries_logical_key_unique ON booking_request_email_deliveries (property_id, booking_request_id, logical_key)`, `CREATE INDEX IF NOT EXISTS booking_request_email_deliveries_property_request_idx ON booking_request_email_deliveries (property_id, booking_request_id)`, `CREATE UNIQUE INDEX IF NOT EXISTS bookings_property_external_channel_unique ON bookings (property_id, external_confirmation, channel_code) WHERE external_confirmation IS NOT NULL AND channel_code IS NOT NULL`, // Stay extras / packages @@ -1946,6 +1953,10 @@ async function main() { ALTER TABLE booking_request_installments ADD CONSTRAINT booking_request_installments_request_fkey FOREIGN KEY (property_id, booking_request_id) REFERENCES booking_requests(property_id, id) NOT VALID; END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'booking_request_email_deliveries_request_fkey') THEN + ALTER TABLE booking_request_email_deliveries ADD CONSTRAINT booking_request_email_deliveries_request_fkey + FOREIGN KEY (property_id, booking_request_id) REFERENCES booking_requests(property_id, id) NOT VALID; + END IF; IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_booking_request_fkey') THEN ALTER TABLE payments ADD CONSTRAINT payments_booking_request_fkey FOREIGN KEY (property_id, booking_request_id) REFERENCES booking_requests(property_id, id) NOT VALID; @@ -1964,6 +1975,7 @@ async function main() { `ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_child_shape_check`, `ALTER TABLE booking_request_installments VALIDATE CONSTRAINT booking_request_installments_request_fkey`, `ALTER TABLE booking_request_consequences VALIDATE CONSTRAINT booking_request_consequences_request_fkey`, + `ALTER TABLE booking_request_email_deliveries VALIDATE CONSTRAINT booking_request_email_deliveries_request_fkey`, `ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_fkey`, `ALTER TABLE payments VALIDATE CONSTRAINT payments_booking_request_parent_fkey`, `ALTER TABLE booking_request_payment_resolutions VALIDATE CONSTRAINT booking_request_payment_resolutions_parent_movement_fkey`, diff --git a/packages/database/src/schema/booking-request.ts b/packages/database/src/schema/booking-request.ts index 8176aba0..036b7557 100644 --- a/packages/database/src/schema/booking-request.ts +++ b/packages/database/src/schema/booking-request.ts @@ -331,6 +331,7 @@ export const bookingRequestEmailDeliveries = pgTable('booking_request_email_deli id: uuid('id').primaryKey().defaultRandom(), propertyId: uuid('property_id').notNull().references(() => properties.id), bookingRequestId: uuid('booking_request_id').notNull().references(() => bookingRequests.id), + logicalKey: varchar('logical_key', { length: 200 }).notNull(), kind: bookingRequestEmailDeliveryKindEnum('kind').notNull(), status: bookingRequestEmailDeliveryStatusEnum('status').notNull().default('pending'), recipient: varchar('recipient', { length: 255 }).notNull(), @@ -338,8 +339,17 @@ export const bookingRequestEmailDeliveries = pgTable('booking_request_email_deli bodyText: text('body_text').notNull(), errorMessage: text('error_message'), attempts: integer('attempts').notNull().default(0), + claimedAt: timestamp('claimed_at', { withTimezone: true }), lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }), sentAt: timestamp('sent_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}); +}, (table) => ({ + logicalKeyUnique: uniqueIndex('booking_request_email_deliveries_logical_key_unique') + .on(table.propertyId, table.bookingRequestId, table.logicalKey), + requestOwnership: foreignKey({ + name: 'booking_request_email_deliveries_request_fkey', + columns: [table.propertyId, table.bookingRequestId], + foreignColumns: [bookingRequests.propertyId, bookingRequests.id], + }), +})); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8692fa25..5939df74 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -32,6 +32,11 @@ export const WEBHOOK_EVENTS = { 'payment.refunded': 'payment.refunded', 'payment.failed': 'payment.failed', + // Booking Request events + 'booking_request.created': 'booking_request.created', + 'booking_request.accepted': 'booking_request.accepted', + 'booking_request.denied': 'booking_request.denied', + // Fiscal document / invoice events (regional tax integrations). // Core stores only a document reference on the folio; issuance is performed // by external integrations subscribed to invoice.requested. @@ -151,6 +156,44 @@ export const WEBHOOK_EVENTS = { export type WebhookEvent = keyof typeof WEBHOOK_EVENTS; +export type BookingRequestCreatedWebhook = { + event: 'booking_request.created'; + entityType: 'booking_request'; + entityId: string; + propertyId: string; + data: { requestId: string; status: 'pending' }; + timestamp: string; +}; + +export type BookingRequestAcceptedWebhook = { + event: 'booking_request.accepted'; + entityType: 'booking_request'; + entityId: string; + propertyId: string; + data: { + requestId: string; + reservationId: string; + folioId: string; + priceSource: 'submitted' | 'current' | 'custom'; + acceptedTotal: string; + }; + timestamp: string; +}; + +export type BookingRequestDeniedWebhook = { + event: 'booking_request.denied'; + entityType: 'booking_request'; + entityId: string; + propertyId: string; + data: { requestId: string; status: 'denied' }; + timestamp: string; +}; + +export type BookingRequestWebhook = + | BookingRequestCreatedWebhook + | BookingRequestAcceptedWebhook + | BookingRequestDeniedWebhook; + /** * Brazilian FNRH (Ficha Nacional de Cadastro de Hóspedes) — Ministry of Tourism / Embratur Standards */ @@ -276,4 +319,3 @@ export const isFnrhComplete = checkFnrhComplete; /** Tier-1 source PMS identifiers for automated migration connectors. */ export const MIGRATION_SOURCE_PMS = ['mews', 'cloudbeds', 'apaleo', 'ohip'] as const; export type MigrationSourcePms = (typeof MIGRATION_SOURCE_PMS)[number]; - From 1289cf29799a9ccbe6a07d13d42f37b26dfcdc82 Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 25 Aug 2026 01:01:26 +0200 Subject: [PATCH 29/87] fix(booking-requests): harden email delivery recovery --- .../guest-comms/email-provider.interface.ts | 4 + .../agent/guest-comms/email.service.spec.ts | 29 + .../providers/console-email.provider.ts | 2 +- .../providers/mailgun-email.provider.ts | 4 + .../providers/mailgun-ses.provider.spec.ts | 19 + .../providers/sendgrid-email.provider.ts | 11 +- .../providers/ses-email.provider.ts | 8 + .../providers/smtp-email.provider.ts | 4 + .../booking-request-decision.spec.ts | 7 + .../booking-request-mailer.service.ts | 500 ++++++++++++++---- .../booking-request-mailer.spec.ts | 355 ++++++++++++- .../booking-request-payment-consequence.ts | 40 +- .../booking-request-payment.db.spec.ts | 3 + .../booking-request-payment.service.ts | 24 +- .../booking-request-payment.spec.ts | 18 +- .../booking-request.controller.ts | 3 +- .../booking-request.service.ts | 1 + .../modules/webhook/webhook.service.spec.ts | 19 + .../src/modules/webhook/webhook.service.ts | 7 +- .../booking-request-migration-safety.spec.ts | 15 + .../src/booking-request-schema.spec.ts | 7 + ...026_booking_request_email_retry_policy.sql | 22 + packages/database/src/push-schema.ts | 12 +- .../database/src/schema/booking-request.ts | 8 + packages/shared/src/index.spec.ts | 32 +- packages/shared/src/index.ts | 1 + 26 files changed, 985 insertions(+), 170 deletions(-) create mode 100644 packages/database/src/migrations/0026_booking_request_email_retry_policy.sql 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..7f49ada5 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 { 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..2d64f91a 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,27 @@ 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]); + await service.send({ + to: 'guest@example.com', + subject: 'Hi', + html: '

Hi

', + text: 'Hi', + idempotencyKey: 'booking-request-email:delivery-1', + messageId: '', + }); + expect(provider.send).toHaveBeenCalledWith(expect.objectContaining({ + idempotencyKey: 'booking-request-email:delivery-1', + messageId: '', + })); + }); + 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() }; @@ -92,11 +113,19 @@ 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' }, + }); }); }); 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..14910b27 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 @@ -35,6 +35,10 @@ 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'); 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..6f9cbc4f 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 @@ -33,9 +33,15 @@ 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'); }); }); @@ -73,11 +79,24 @@ 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', + })); }); }); 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..3da6c41b 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 @@ -38,8 +38,17 @@ export class SendgridEmailProvider implements EmailProvider { } 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: [ 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..1d6db84e 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 @@ -37,6 +37,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 }, @@ -52,6 +57,9 @@ export class SesEmailProvider implements EmailProvider { 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), }); 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..907209ed 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 @@ -57,6 +57,10 @@ export class SmtpEmailProvider implements EmailProvider { subject: message.subject, html: message.html, text: message.text, + messageId: message.messageId, + headers: message.idempotencyKey + ? { 'X-HAIP-Idempotency-Key': message.idempotencyKey } + : undefined, }); this.logger.log(`Email sent via SMTP to ${message.to}: ${info.messageId}`); 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 index c2f5d0d3..8ebbfbb4 100644 --- a/apps/api/src/modules/booking-request/booking-request-decision.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-decision.spec.ts @@ -672,6 +672,13 @@ describe('BookingRequestService acceptance', () => { '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); }, 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 index 867fdf50..00490ce7 100644 --- a/apps/api/src/modules/booking-request/booking-request-mailer.service.ts +++ b/apps/api/src/modules/booking-request/booking-request-mailer.service.ts @@ -1,4 +1,10 @@ -import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + ConflictException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { auditLogs, bookingRequestEmailDeliveries, @@ -6,7 +12,12 @@ import { } from '@telivityhaip/database'; 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 = @@ -22,11 +33,31 @@ export type QueueBookingRequestEmail = { 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 { @@ -45,6 +76,7 @@ export class BookingRequestMailerService { 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({ @@ -57,6 +89,8 @@ export class BookingRequestMailerService { bodyText: input.bodyText, status: 'pending', attempts: 0, + automaticAttempts: 0, + nextAttemptAt: queuedAt, }) .onConflictDoNothing() .returning({ id: bookingRequestEmailDeliveries.id }); @@ -93,7 +127,10 @@ export class BookingRequestMailerService { return existing.id; } - async listForRequest(bookingRequestId: string, propertyId: string): Promise { + async listForRequest( + bookingRequestId: string, + propertyId: string, + ): Promise { await this.assertRequestScope(bookingRequestId, propertyId); const rows = await this.db .select() @@ -103,61 +140,86 @@ export class BookingRequestMailerService { eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), )) .orderBy(asc(bookingRequestEmailDeliveries.createdAt)); - return rows.filter((row: Delivery) => - row.propertyId === propertyId && row.bookingRequestId === bookingRequestId); + 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, + mode: DeliveryMode = 'automatic', + actor?: AuditActor, ): Promise { - const claimed = await this.claim(deliveryId, bookingRequestId, propertyId); - if (!claimed || claimed.status === 'sent') return claimed; + const claimed = await this.claim(deliveryId, bookingRequestId, propertyId, mode, actor); + if (!claimed || claimed.status !== 'processing') return claimed; - let sent: boolean; - try { - const result = await this.emailService.send({ - to: claimed.recipient, - subject: claimed.subject, - text: claimed.bodyText, - html: this.textAsHtml(claimed.bodyText), - }); - sent = result.sent; - } catch { - sent = false; - } + const transportIdentity = this.transportIdentity(claimed.id); + const transportResult = await this.sendWithTimeout({ + to: claimed.recipient, + subject: claimed.subject, + text: claimed.bodyText, + html: this.textAsHtml(claimed.bodyText), + idempotencyKey: transportIdentity.idempotencyKey, + messageId: transportIdentity.messageId, + }); - const finishedAt = new Date(); - const errorMessage = sent ? null : 'Email transport failed'; - const [updated] = await this.db - .update(bookingRequestEmailDeliveries) - .set({ - status: sent ? 'sent' : 'failed', - claimedAt: null, - errorMessage, - sentAt: sent ? finishedAt : null, - updatedAt: finishedAt, - }) - .where(and( - eq(bookingRequestEmailDeliveries.id, claimed.id), - eq(bookingRequestEmailDeliveries.propertyId, propertyId), - eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), - eq(bookingRequestEmailDeliveries.claimedAt, claimed.claimedAt!), - )) - .returning(); - const result = updated ?? { ...claimed, status: sent ? 'sent' : 'failed', errorMessage }; - await this.auditAttemptBestEffort(result); - return result; + return this.finalizeAttempt(claimed, transportResult, mode, actor); } async retry( deliveryId: string, bookingRequestId: string, propertyId: string, - ): Promise { - await this.findDelivery(deliveryId, bookingRequestId, propertyId); - return this.deliver(deliveryId, bookingRequestId, propertyId); + actor: AuditActor, + ): Promise { + 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 requeuedAt = new Date(); + const [requeued] = await tx + .update(bookingRequestEmailDeliveries) + .set({ + status: 'pending', + automaticAttempts: 0, + claimedAt: null, + nextAttemptAt: requeuedAt, + errorMessage: null, + providerMessageId: null, + updatedAt: requeuedAt, + }) + .where(and( + eq(bookingRequestEmailDeliveries.id, deliveryId), + eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), + eq(bookingRequestEmailDeliveries.propertyId, propertyId), + eq(bookingRequestEmailDeliveries.status, 'failed'), + )) + .returning(); + if (!requeued) throw new ConflictException('Email delivery retry state changed'); + await tx.insert(auditLogs).values({ + propertyId, + action: 'update', + entityType: 'booking_request_email_delivery', + entityId: deliveryId, + ...actorFields(actor), + previousValue: { status: 'failed' }, + newValue: { status: 'pending', automaticAttempts: 0 }, + description: 'Booking request email manually requeued', + }); + }); + + const result = await this.deliver( + deliveryId, + bookingRequestId, + propertyId, + 'manual', + actor, + ); + return result ? this.toView(result) : undefined; } async deliverForRequestBestEffort( @@ -165,9 +227,10 @@ export class BookingRequestMailerService { propertyId: string, ): Promise { try { - const deliveries = await this.listForRequest(bookingRequestId, propertyId); + const now = new Date(); + const deliveries = await this.scopedDeliveries(bookingRequestId, propertyId); for (const delivery of deliveries) { - if (delivery.status === 'sent') continue; + if (!this.isAutomaticallyEligible(delivery, now)) continue; await this.deliver(delivery.id, bookingRequestId, propertyId); } } catch (error: unknown) { @@ -179,76 +242,307 @@ export class BookingRequestMailerService { } async processPendingDeliveries(limit = 100): Promise { - const staleBefore = new Date(Date.now() - CLAIM_LEASE_MS); + const now = new Date(); + const staleBefore = new Date(now.getTime() - CLAIM_LEASE_MS); const rows = await this.db .select() .from(bookingRequestEmailDeliveries) .where(and( or( - eq(bookingRequestEmailDeliveries.status, 'pending'), - eq(bookingRequestEmailDeliveries.status, 'failed'), - ), - or( - isNull(bookingRequestEmailDeliveries.claimedAt), - lte(bookingRequestEmailDeliveries.claimedAt, staleBefore), + 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.createdAt)) + .orderBy(asc(bookingRequestEmailDeliveries.nextAttemptAt)) .limit(Math.max(1, Math.min(limit, 500))); - const recoverable = rows.filter((row: Delivery) => - row.status !== 'sent' - && (!row.claimedAt || row.claimedAt.getTime() <= staleBefore.getTime())); - for (const row of recoverable) { - await this.deliver(row.id, row.bookingRequestId, row.propertyId); + 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 recoverable.length; + return candidates.length; } private async claim( deliveryId: string, bookingRequestId: string, propertyId: string, + mode: DeliveryMode, + actor?: AuditActor, ): Promise { return this.db.transaction(async (tx) => { - const rows = await tx - .select() - .from(bookingRequestEmailDeliveries) - .where(and( - eq(bookingRequestEmailDeliveries.id, deliveryId), - eq(bookingRequestEmailDeliveries.bookingRequestId, bookingRequestId), - eq(bookingRequestEmailDeliveries.propertyId, propertyId), - )) - .for('update'); - 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`); - if (row.status === 'sent') return row; - if (row.claimedAt && row.claimedAt.getTime() > Date.now() - CLAIM_LEASE_MS) { - return undefined; - } - const attemptedAt = new Date(); + const row = await this.findDelivery(tx, deliveryId, bookingRequestId, propertyId, true); + const now = new Date(); + if (row.status === 'sent' || row.status === 'failed') return row; + if (mode === 'automatic' && !this.isAutomaticallyEligible(row, now)) return undefined; + if (mode === 'manual' && row.status !== 'pending') return undefined; + + const leaseUntil = new Date(now.getTime() + CLAIM_LEASE_MS); const [claimed] = await tx .update(bookingRequestEmailDeliveries) .set({ - status: 'pending', + status: 'processing', attempts: row.attempts + 1, - claimedAt: attemptedAt, - lastAttemptAt: attemptedAt, + automaticAttempts: row.automaticAttempts + (mode === 'automatic' ? 1 : 0), + claimedAt: now, + nextAttemptAt: leaseUntil, + lastAttemptAt: now, errorMessage: null, - updatedAt: attemptedAt, + 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, + action: 'update', + entityType: 'booking_request_email_delivery', + entityId: deliveryId, + ...actorFields(actor), + 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 + && 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 : '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, + 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 async sendWithTimeout( + message: Parameters[0], + ): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + this.emailService.send(message).catch(() => ({ sent: false } as EmailResult)), + new Promise((resolve) => { + timeout = setTimeout( + () => resolve({ sent: false, error: 'Email transport timed out' }), + SEND_TIMEOUT_MS, + ); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + + 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, + 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 }) @@ -264,11 +558,13 @@ export class BookingRequestMailerService { } private async findDelivery( + executor: Pick, deliveryId: string, bookingRequestId: string, propertyId: string, + lock: boolean, ): Promise { - const rows = await this.db + const query = executor .select() .from(bookingRequestEmailDeliveries) .where(and( @@ -276,6 +572,7 @@ export class BookingRequestMailerService { 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 @@ -284,30 +581,21 @@ export class BookingRequestMailerService { return row; } - private async auditAttemptBestEffort(delivery: Delivery): Promise { - try { - await this.db.insert(auditLogs).values({ - propertyId: delivery.propertyId, - action: 'update', - entityType: 'booking_request_email_delivery', - entityId: delivery.id, - description: delivery.status === 'sent' - ? 'Booking request email delivered' - : 'Booking request email delivery failed', - newValue: { - bookingRequestId: delivery.bookingRequestId, - kind: delivery.kind, - status: delivery.status, - attempts: delivery.attempts, - ...(delivery.errorMessage ? { error: delivery.errorMessage } : {}), - }, - }); - } catch (error: unknown) { - this.logger.error( - `Email delivery ${delivery.id} state changed but its audit write failed`, - error instanceof Error ? error.stack : undefined, - ); - } + 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 { 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 index 93eaa4a4..61ef91dd 100644 --- a/apps/api/src/modules/booking-request/booking-request-mailer.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-mailer.spec.ts @@ -7,6 +7,7 @@ import { } from '@telivityhaip/database'; 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 { @@ -40,8 +41,11 @@ function delivery(overrides: Partial = {}): Delivery { 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, @@ -49,16 +53,30 @@ function delivery(overrides: Partial = {}): Delivery { }; } -function createHarness(seed: Delivery[] = []) { +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>, }; + 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(); } @@ -114,6 +132,14 @@ function createHarness(seed: Delivery[] = []) { 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 []; + } Object.assign(current, changes); return [current]; }, @@ -132,7 +158,17 @@ function createHarness(seed: Delivery[] = []) { insert, select, update, - transaction: (work: (tx: any) => unknown) => work(db), + transaction: async (work: (tx: any) => unknown) => { + const deliveriesBefore = structuredClone(state.deliveries); + const auditsBefore = structuredClone(state.audits); + try { + return await work(db); + } catch (error) { + state.deliveries.splice(0, state.deliveries.length, ...deliveriesBefore); + state.audits.splice(0, state.audits.length, ...auditsBefore); + throw error; + } + }, }; const emailService = { send: vi.fn() }; const service = new BookingRequestMailerService(db, emailService as any); @@ -186,12 +222,17 @@ describe('Booking Request email templates', () => { }); describe('BookingRequestMailerService', () => { - beforeEach(() => vi.restoreAllMocks()); + 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: 'pending', attempts: 1 }); + expect(h.state.deliveries[0]).toMatchObject({ + status: 'processing', attempts: 1, automaticAttempts: 1, + }); return { sent: true, provider: 'smtp', messageId: 'provider-message-id' }; }); const input = { @@ -211,27 +252,36 @@ describe('BookingRequestMailerService', () => { 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 }); + 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('records a safe failed result without throwing and retries it durably', async () => { - const h = createHarness([delivery()]); - h.emailService.send - .mockRejectedValueOnce(new Error('smtp password secret-token and pm_123 leaked')) - .mockResolvedValueOnce({ sent: true, provider: 'smtp' }); + 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: 'failed', + 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); - - const sent = await h.service.retry(DELIVERY_ID, REQUEST_ID, PROPERTY_ID); - expect(sent).toMatchObject({ status: 'sent', attempts: 2, errorMessage: null }); - expect(h.emailService.send).toHaveBeenCalledTimes(2); + 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 () => { @@ -245,24 +295,266 @@ describe('BookingRequestMailerService', () => { 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)) + 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]).toMatchObject({ propertyId: PROPERTY_ID, bookingRequestId: REQUEST_ID }); + 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 pending and failed deliveries while never redelivering sent mail', async () => { + 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: 'failed' }), - delivery({ id: 'cccccccc-0000-4000-a000-000000000003', status: 'sent' }), + 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.every((row) => row.status === 'sent')).toBe(true); + 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('manually requeues a terminal failure with attributed transactional audits', 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: true, provider: 'smtp', messageId: 'provider-retry-id', + }); + + const retried = await h.service.retry(DELIVERY_ID, REQUEST_ID, PROPERTY_ID, actor); + + 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 manually requeued', + 'Booking request email delivery attempted', + 'Booking request email delivered', + ]); + expect(h.state.audits).toEqual(expect.arrayContaining([ + expect.objectContaining(actor), + ])); + }); + + it('rolls back a manual requeue 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 manually requeued', + 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('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('bounds a hung transport below the lease and schedules recovery', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-25T00:00:00.000Z')); + const h = createHarness([delivery()]); + h.emailService.send.mockReturnValue(new Promise(() => undefined)); + + const attempt = h.service.deliver(DELIVERY_ID, REQUEST_ID, PROPERTY_ID); + await vi.advanceTimersByTimeAsync(60_000); + + await expect(attempt).resolves.toMatchObject({ + status: 'pending', + claimedAt: null, + nextAttemptAt: new Date('2026-08-25T00:01:30.000Z'), + }); + }); + + 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', + ]); }); }); @@ -275,6 +567,27 @@ describe('Booking Request email API and webhook contract', () => { .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', 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 index c734ca12..0cc5f9cc 100644 --- a/apps/api/src/modules/booking-request/booking-request-payment-consequence.ts +++ b/apps/api/src/modules/booking-request/booking-request-payment-consequence.ts @@ -1,9 +1,11 @@ import { + auditLogs, bookingRequestConsequences, bookingRequestEmailDeliveries, bookingRequests, } from '@telivityhaip/database'; import type { BookingRequestConsequenceKind } from '@telivityhaip/database'; +import type { WebhookEvent } from '@telivityhaip/shared'; import { and, eq } from 'drizzle-orm'; import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { @@ -13,18 +15,12 @@ import { } from './booking-request-email.templates'; export type BookingRequestFinancialEvent = - | 'payment.received' - | 'payment.failed' - | 'payment.refunded' - | 'payment.external_returned' - | 'payment.retained'; + Extract; const kindPrefix: Record = { 'payment.received': 'payment_received', 'payment.failed': 'payment_failed', 'payment.refunded': 'payment_refunded', - 'payment.external_returned': 'external_returned', - 'payment.retained': 'payment_retained', }; type FinancialConsequenceExecutor = Pick; @@ -76,7 +72,6 @@ async function ensureFinancialEmail( tx: FinancialConsequenceExecutor, input: Parameters[1], ): Promise { - if (input.event === 'payment.retained') return; const amount = firstString( input.data['amount'], input.data['refundAmount'], @@ -108,17 +103,14 @@ async function ensureFinancialEmail( currencyCode, source: input.data['source'] === 'external' ? 'external' : 'saved_card', }); - } else if ( - input.event === 'payment.refunded' - || input.event === 'payment.external_returned' - ) { + } else if (input.event === 'payment.refunded') { kind = 'refund'; logicalPrefix = 'refund'; content = refundedBookingRequestPaymentEmail({ guestFirstName: request.guestFirstName, amount, currencyCode, - source: input.event === 'payment.external_returned' ? 'external_return' : 'refund', + source: input.data['source'] === 'external_return' ? 'external_return' : 'refund', }); } else { kind = 'failure'; @@ -131,7 +123,8 @@ async function ensureFinancialEmail( }); } - await tx + const queuedAt = new Date(); + const [created] = await tx .insert(bookingRequestEmailDeliveries) .values({ propertyId: input.propertyId, @@ -143,8 +136,25 @@ async function ensureFinancialEmail( subject: content.subject, bodyText: content.bodyText, attempts: 0, + automaticAttempts: 0, + nextAttemptAt: queuedAt, }) - .onConflictDoNothing(); + .onConflictDoNothing() + .returning({ id: bookingRequestEmailDeliveries.id }); + if (created) { + await tx.insert(auditLogs).values({ + propertyId: input.propertyId, + 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 { 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 index 96fdf5be..e64af3e6 100644 --- 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 @@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs'; import { auditLogs, bookingRequestConsequences, + bookingRequestEmailDeliveries, bookingRequestInstallments, bookingRequestPaymentAllocations, bookingRequestPaymentResolutions, @@ -159,6 +160,8 @@ describeDatabase('Booking Request payment PostgreSQL concurrency contract', () = .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)); 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 index ea8bed2a..f755b6da 100644 --- a/apps/api/src/modules/booking-request/booking-request-payment.service.ts +++ b/apps/api/src/modules/booking-request/booking-request-payment.service.ts @@ -1067,7 +1067,7 @@ export class BookingRequestPaymentService { await this.folioService.recalculateBalance(existing.folioId, propertyId, tx); } await ensureBookingRequestFinancialConsequence(tx, { - event: 'payment.external_returned', + event: 'payment.refunded', logicalId: existing.id, propertyId, bookingRequestId, @@ -1080,6 +1080,7 @@ export class BookingRequestPaymentService { resolutionId: resolution.id, currencyCode: original.currencyCode, source: 'external_return', + method: original.method, }, }); return { movement: existing, resolution, isNew: false }; @@ -1138,7 +1139,7 @@ export class BookingRequestPaymentService { description: 'External booking request payment return recorded', }); await ensureBookingRequestFinancialConsequence(tx, { - event: 'payment.external_returned', + event: 'payment.refunded', logicalId: movement.id, propertyId, bookingRequestId, @@ -1151,6 +1152,7 @@ export class BookingRequestPaymentService { resolutionId: resolution.id, currencyCode: original.currencyCode, source: 'external_return', + method: original.method, }, }); await this.reconcileAllocationsForPayment( @@ -1201,15 +1203,6 @@ export class BookingRequestPaymentService { && new Decimal(row.amount).eq(amount) && row.reason?.trim() === reason); if (existing) { - await ensureBookingRequestFinancialConsequence(tx, { - event: 'payment.retained', - logicalId: existing.id, - propertyId, - bookingRequestId, - entityType: 'booking_request_payment_resolution', - entityId: existing.id, - data: { paymentId, amount: existing.amount, reason: existing.reason }, - }); return existing; } await this.assertResolutionCapacity( @@ -1228,15 +1221,6 @@ export class BookingRequestPaymentService { reason, actor, }); - await ensureBookingRequestFinancialConsequence(tx, { - event: 'payment.retained', - logicalId: resolution.id, - propertyId, - bookingRequestId, - entityType: 'booking_request_payment_resolution', - entityId: resolution.id, - data: { paymentId, amount: resolution.amount, reason: resolution.reason }, - }); return resolution; }); return this.resolutionResponse(resolution); 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 index 4e208ebc..ef547797 100644 --- a/apps/api/src/modules/booking-request/booking-request-payment.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-payment.spec.ts @@ -12,6 +12,7 @@ import { bookingRequests, payments, } from '@telivityhaip/database'; +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'; @@ -663,8 +664,14 @@ describe('BookingRequestPaymentService saved-card charges', () => { 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, ); @@ -1753,8 +1760,16 @@ describe('BookingRequestPaymentService external movements and denial resolutions movementId: result.movement.id, }); expect(harness.state.consequences).toEqual([ - expect.objectContaining({ kind: expect.stringMatching(/^external_returned:/) }), + 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:/) }), ]); @@ -1937,6 +1952,7 @@ describe('BookingRequestPaymentService external movements and denial resolutions reason: 'Non-refundable supplier cost', resolvedBy: actor.userId, }); + expect(harness.state.consequences).toHaveLength(0); expect(harness.state.emails).toHaveLength(0); }); diff --git a/apps/api/src/modules/booking-request/booking-request.controller.ts b/apps/api/src/modules/booking-request/booking-request.controller.ts index db5d561b..cf3f72ef 100644 --- a/apps/api/src/modules/booking-request/booking-request.controller.ts +++ b/apps/api/src/modules/booking-request/booking-request.controller.ts @@ -86,8 +86,9 @@ export class BookingRequestController { @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); + return this.mailer.retry(deliveryId, id, propertyId, actor); } @Post(':id/accept') diff --git a/apps/api/src/modules/booking-request/booking-request.service.ts b/apps/api/src/modules/booking-request/booking-request.service.ts index c66cd3c5..24e2ab2c 100644 --- a/apps/api/src/modules/booking-request/booking-request.service.ts +++ b/apps/api/src/modules/booking-request/booking-request.service.ts @@ -434,6 +434,7 @@ export class BookingRequestService { folioId: folio.id, priceSource: pricing.source, acceptedTotal: pricing.grandTotal, + currencyCode: locked.currencyCode, }, timestamp: decidedAt.toISOString(), } satisfies BookingRequestAcceptedWebhook; diff --git a/apps/api/src/modules/webhook/webhook.service.spec.ts b/apps/api/src/modules/webhook/webhook.service.spec.ts index 69886697..c6a50025 100644 --- a/apps/api/src/modules/webhook/webhook.service.spec.ts +++ b/apps/api/src/modules/webhook/webhook.service.spec.ts @@ -36,4 +36,23 @@ describe('WebhookService persisted dispatch', () => { 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 5b372c53..cfda8f32 100644 --- a/apps/api/src/modules/webhook/webhook.service.ts +++ b/apps/api/src/modules/webhook/webhook.service.ts @@ -1,11 +1,11 @@ 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; @@ -30,6 +30,9 @@ export class WebhookService { 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, diff --git a/packages/database/src/booking-request-migration-safety.spec.ts b/packages/database/src/booking-request-migration-safety.spec.ts index 9d78b111..497cd903 100644 --- a/packages/database/src/booking-request-migration-safety.spec.ts +++ b/packages/database/src/booking-request-migration-safety.spec.ts @@ -18,6 +18,10 @@ const emailRecoveryMigration = readFileSync( new URL('./migrations/0025_booking_request_email_recovery.sql', import.meta.url), 'utf8', ); +const emailRetryMigration = readFileSync( + new URL('./migrations/0026_booking_request_email_retry_policy.sql', import.meta.url), + 'utf8', +); describe('booking request accepted-pricing migration safety', () => { it('fails instead of accepting an already-accepted request without an operational snapshot', () => { @@ -126,4 +130,15 @@ describe('booking request email recovery migration safety', () => { expect(backfill).toBeGreaterThan(addColumn); expect(notNull).toBeGreaterThan(backfill); }); + + it('adds bounded retry scheduling, provider receipt identity, and a partial recovery index', () => { + for (const source of [emailRetryMigration, pushSchema]) { + expect(source).toContain("ADD VALUE IF NOT EXISTS 'processing'"); + expect(source).toContain('ADD COLUMN IF NOT EXISTS next_attempt_at'); + expect(source).toContain('ADD COLUMN IF NOT EXISTS automatic_attempts'); + expect(source).toContain('ADD COLUMN IF NOT EXISTS provider_message_id'); + expect(source).toContain('booking_request_email_deliveries_recovery_idx'); + expect(source).toMatch(/WHERE status IN \('pending', 'processing'\)/i); + } + }); }); diff --git a/packages/database/src/booking-request-schema.spec.ts b/packages/database/src/booking-request-schema.spec.ts index 91b0b217..eb73438a 100644 --- a/packages/database/src/booking-request-schema.spec.ts +++ b/packages/database/src/booking-request-schema.spec.ts @@ -41,6 +41,10 @@ describe('booking request schema', () => { expect(bookingRequestPaymentResolutions.lastError).toBeDefined(); expect(bookingRequestEmailDeliveries.logicalKey).toBeDefined(); expect(bookingRequestEmailDeliveries.claimedAt).toBeDefined(); + expect(bookingRequestEmailDeliveries.nextAttemptAt).toBeDefined(); + expect(bookingRequestEmailDeliveries.automaticAttempts).toBeDefined(); + expect(bookingRequestEmailDeliveries.providerMessageId).toBeDefined(); + expect(bookingRequestEmailDeliveries.status.enumValues).toContain('processing'); expect(bookingEngineConfig.bookingMode).toBeDefined(); expect(bookingEngineConfig.paymentMethodCollection).toBeDefined(); expect(payments.bookingRequestId).toBeDefined(); @@ -100,6 +104,9 @@ describe('booking request schema', () => { expect(emailConfig.indexes.map((index) => index.config.name)).toContain( 'booking_request_email_deliveries_logical_key_unique', ); + expect(emailConfig.indexes.map((index) => index.config.name)).toContain( + 'booking_request_email_deliveries_recovery_idx', + ); expect(emailConfig.foreignKeys.map((key) => key.getName())).toContain( 'booking_request_email_deliveries_request_fkey', ); diff --git a/packages/database/src/migrations/0026_booking_request_email_retry_policy.sql b/packages/database/src/migrations/0026_booking_request_email_retry_policy.sql new file mode 100644 index 00000000..4925edb2 --- /dev/null +++ b/packages/database/src/migrations/0026_booking_request_email_retry_policy.sql @@ -0,0 +1,22 @@ +ALTER TYPE booking_request_email_delivery_status + ADD VALUE IF NOT EXISTS 'processing'; + +ALTER TABLE booking_request_email_deliveries + ADD COLUMN IF NOT EXISTS automatic_attempts integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS next_attempt_at timestamptz, + ADD COLUMN IF NOT EXISTS provider_message_id varchar(500); + +UPDATE booking_request_email_deliveries +SET status = 'processing', + next_attempt_at = COALESCE(next_attempt_at, claimed_at) +WHERE status = 'pending' + AND claimed_at IS NOT NULL; + +UPDATE booking_request_email_deliveries +SET next_attempt_at = COALESCE(next_attempt_at, last_attempt_at, created_at, now()) +WHERE status = 'pending' + AND claimed_at IS NULL; + +CREATE INDEX IF NOT EXISTS booking_request_email_deliveries_recovery_idx + ON booking_request_email_deliveries (status, next_attempt_at, claimed_at) + WHERE status IN ('pending', 'processing'); diff --git a/packages/database/src/push-schema.ts b/packages/database/src/push-schema.ts index 1c724a58..5ac93c30 100644 --- a/packages/database/src/push-schema.ts +++ b/packages/database/src/push-schema.ts @@ -36,7 +36,8 @@ async function main() { `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_installment_status') THEN CREATE TYPE booking_request_installment_status AS ENUM ('unpaid','partial','paid'); END IF; END $$`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_payment_resolution_type') THEN CREATE TYPE booking_request_payment_resolution_type AS ENUM ('refund','external_return','retained'); END IF; END $$`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_email_delivery_kind') THEN CREATE TYPE booking_request_email_delivery_kind AS ENUM ('receipt','accepted','denied','payment','refund','failure'); END IF; END $$`, - `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_email_delivery_status') THEN CREATE TYPE booking_request_email_delivery_status AS ENUM ('pending','sent','failed'); END IF; END $$`, + `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'booking_request_email_delivery_status') THEN CREATE TYPE booking_request_email_delivery_status AS ENUM ('pending','processing','sent','failed'); END IF; END $$`, + `ALTER TYPE booking_request_email_delivery_status ADD VALUE IF NOT EXISTS 'processing'`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'housekeeping_task_status') THEN CREATE TYPE housekeeping_task_status AS ENUM ('pending','assigned','in_progress','completed','inspected','skipped'); END IF; END $$`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'housekeeping_task_type') THEN CREATE TYPE housekeeping_task_type AS ENUM ('checkout','stayover','deep_clean','inspection','turndown','maintenance'); END IF; END $$`, `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'hk_occupancy') THEN CREATE TYPE hk_occupancy AS ENUM ('unknown','vacant','occupied'); END IF; END $$`, @@ -1254,17 +1255,26 @@ async function main() { body_text text NOT NULL, error_message text, attempts integer NOT NULL DEFAULT 0, + automatic_attempts integer NOT NULL DEFAULT 0, claimed_at timestamptz, + next_attempt_at timestamptz, last_attempt_at timestamptz, + provider_message_id varchar(500), sent_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() )`, `ALTER TABLE booking_request_email_deliveries ADD COLUMN IF NOT EXISTS logical_key varchar(200)`, `ALTER TABLE booking_request_email_deliveries ADD COLUMN IF NOT EXISTS claimed_at timestamptz`, + `ALTER TABLE booking_request_email_deliveries ADD COLUMN IF NOT EXISTS automatic_attempts integer NOT NULL DEFAULT 0`, + `ALTER TABLE booking_request_email_deliveries ADD COLUMN IF NOT EXISTS next_attempt_at timestamptz`, + `ALTER TABLE booking_request_email_deliveries ADD COLUMN IF NOT EXISTS provider_message_id varchar(500)`, `UPDATE booking_request_email_deliveries SET logical_key = 'task8-legacy:' || id::text WHERE logical_key IS NULL`, + `UPDATE booking_request_email_deliveries SET status = 'processing', next_attempt_at = COALESCE(next_attempt_at, claimed_at) WHERE status = 'pending' AND claimed_at IS NOT NULL`, + `UPDATE booking_request_email_deliveries SET next_attempt_at = COALESCE(next_attempt_at, last_attempt_at, created_at, now()) WHERE status = 'pending' AND claimed_at IS NULL`, `ALTER TABLE booking_request_email_deliveries ALTER COLUMN logical_key SET NOT NULL`, `CREATE UNIQUE INDEX IF NOT EXISTS booking_request_email_deliveries_logical_key_unique ON booking_request_email_deliveries (property_id, booking_request_id, logical_key)`, + `CREATE INDEX IF NOT EXISTS booking_request_email_deliveries_recovery_idx ON booking_request_email_deliveries (status, next_attempt_at, claimed_at) WHERE status IN ('pending', 'processing')`, `CREATE INDEX IF NOT EXISTS booking_request_email_deliveries_property_request_idx ON booking_request_email_deliveries (property_id, booking_request_id)`, `CREATE UNIQUE INDEX IF NOT EXISTS bookings_property_external_channel_unique ON bookings (property_id, external_confirmation, channel_code) WHERE external_confirmation IS NOT NULL AND channel_code IS NOT NULL`, // Stay extras / packages diff --git a/packages/database/src/schema/booking-request.ts b/packages/database/src/schema/booking-request.ts index 036b7557..955a0e9c 100644 --- a/packages/database/src/schema/booking-request.ts +++ b/packages/database/src/schema/booking-request.ts @@ -3,6 +3,7 @@ import { date, foreignKey, integer, + index, jsonb, numeric, pgEnum, @@ -63,6 +64,7 @@ export const bookingRequestEmailDeliveryKindEnum = pgEnum('booking_request_email export const bookingRequestEmailDeliveryStatusEnum = pgEnum('booking_request_email_delivery_status', [ 'pending', + 'processing', 'sent', 'failed', ]); @@ -339,14 +341,20 @@ export const bookingRequestEmailDeliveries = pgTable('booking_request_email_deli bodyText: text('body_text').notNull(), errorMessage: text('error_message'), attempts: integer('attempts').notNull().default(0), + automaticAttempts: integer('automatic_attempts').notNull().default(0), claimedAt: timestamp('claimed_at', { withTimezone: true }), + nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }), lastAttemptAt: timestamp('last_attempt_at', { withTimezone: true }), + providerMessageId: varchar('provider_message_id', { length: 500 }), sentAt: timestamp('sent_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ logicalKeyUnique: uniqueIndex('booking_request_email_deliveries_logical_key_unique') .on(table.propertyId, table.bookingRequestId, table.logicalKey), + recoveryIndex: index('booking_request_email_deliveries_recovery_idx') + .on(table.status, table.nextAttemptAt, table.claimedAt) + .where(sql`${table.status} IN ('pending', 'processing')`), requestOwnership: foreignKey({ name: 'booking_request_email_deliveries_request_fkey', columns: [table.propertyId, table.bookingRequestId], diff --git a/packages/shared/src/index.spec.ts b/packages/shared/src/index.spec.ts index c755db1d..0caf6a43 100644 --- a/packages/shared/src/index.spec.ts +++ b/packages/shared/src/index.spec.ts @@ -1,5 +1,35 @@ import { describe, it, expect } from 'vitest'; -import { validateCpf, formatCpf, calculateAge, checkFnrhComplete } from './index.js'; +import { + validateCpf, + formatCpf, + calculateAge, + checkFnrhComplete, + WEBHOOK_EVENTS, + type BookingRequestAcceptedWebhook, + type WebhookEvent, +} from './index.js'; + +describe('Booking Request webhook contracts', () => { + it('keeps accepted currency and canonical financial dispatch names typed', () => { + const accepted: BookingRequestAcceptedWebhook = { + event: 'booking_request.accepted', + entityType: 'booking_request', + entityId: 'request-1', + propertyId: 'property-1', + data: { + requestId: 'request-1', + reservationId: 'reservation-1', + folioId: 'folio-1', + priceSource: 'submitted', + acceptedTotal: '100.00', + currencyCode: 'EUR', + }, + timestamp: '2026-08-25T00:00:00.000Z', + }; + const events: WebhookEvent[] = [accepted.event, 'payment.refunded']; + expect(events.every((event) => WEBHOOK_EVENTS[event] === event)).toBe(true); + }); +}); describe('CPF validation and formatting helpers', () => { it('should validate valid CPF numbers', () => { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5939df74..d65384c2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -176,6 +176,7 @@ export type BookingRequestAcceptedWebhook = { folioId: string; priceSource: 'submitted' | 'current' | 'custom'; acceptedTotal: string; + currencyCode: string; }; timestamp: string; }; From 6bea578ae565c8a942652a1f3065f282fb3939e2 Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 25 Aug 2026 01:31:06 +0200 Subject: [PATCH 30/87] fix(booking-requests): serialize email retry delivery --- apps/api/package.json | 1 + .../guest-comms/email-provider.interface.ts | 9 +- .../agent/guest-comms/email.service.spec.ts | 45 +++++- .../agent/guest-comms/email.service.ts | 13 +- .../providers/bounded-email-transport.ts | 63 ++++++++ .../providers/mailgun-email.provider.ts | 46 ++++-- .../providers/mailgun-ses.provider.spec.ts | 98 ++++++++++++ .../providers/sendgrid-email.provider.ts | 42 +++-- .../providers/ses-email.provider.ts | 54 +++++-- .../providers/smtp-email.provider.spec.ts | 57 +++++++ .../providers/smtp-email.provider.ts | 52 +++++- .../booking-request-mailer.service.ts | 94 +++++------ .../booking-request-mailer.spec.ts | 151 ++++++++++++++++-- pnpm-lock.yaml | 9 ++ 14 files changed, 611 insertions(+), 123 deletions(-) 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 diff --git a/apps/api/package.json b/apps/api/package.json index a1ed495b..67ea9504 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -42,6 +42,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/modules/agent/guest-comms/email-provider.interface.ts b/apps/api/src/modules/agent/guest-comms/email-provider.interface.ts index 7f49ada5..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 @@ -15,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 2d64f91a..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 @@ -43,18 +43,19 @@ describe('EmailService', () => { send: vi.fn().mockResolvedValue({ sent: true, messageId: 'provider-id' }), }; const service = new EmailService([provider]); - await service.send({ + 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 () => { @@ -77,6 +78,7 @@ describe('SendgridEmailProvider', () => { const originalEnv = { ...process.env }; afterEach(() => { + vi.useRealTimers(); global.fetch = originalFetch; process.env = { ...originalEnv }; vi.resetModules(); @@ -128,4 +130,41 @@ describe('SendgridEmailProvider', () => { 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/mailgun-email.provider.ts b/apps/api/src/modules/agent/guest-comms/providers/mailgun-email.provider.ts index 14910b27..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' }; } @@ -42,18 +52,25 @@ export class MailgunEmailProvider implements EmailProvider { 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, @@ -64,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 6f9cbc4f..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(); @@ -43,6 +44,70 @@ describe('MailgunEmailProvider', () => { 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); + }); }); describe('SesEmailProvider', () => { @@ -50,6 +115,7 @@ describe('SesEmailProvider', () => { const originalEnv = { ...process.env }; afterEach(() => { + vi.useRealTimers(); global.fetch = originalFetch; process.env = { ...originalEnv }; vi.resetModules(); @@ -99,4 +165,36 @@ describe('SesEmailProvider', () => { 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 3da6c41b..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,7 +42,7 @@ 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' }; } @@ -58,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}` }; } @@ -77,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 1d6db84e..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, @@ -51,22 +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, - ...(message.idempotencyKey - ? { 'X-HAIP-Idempotency-Key': message.idempotencyKey } - : {}), + 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, @@ -77,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..19247922 --- /dev/null +++ b/apps/api/src/modules/agent/guest-comms/providers/smtp-email.provider.spec.ts @@ -0,0 +1,57 @@ +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()); + }); + } + }); +}); 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 907209ed..821d9607 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,14 @@ 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'; /** * SMTP transport (nodemailer) — configured via SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM. @@ -8,7 +17,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 +38,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,17 +52,31 @@ 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, + connectionTimeout: timeoutMs, + greetingTimeout: timeoutMs, + socketTimeout: timeoutMs, + dnsTimeout: timeoutMs, + }); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + transport.close?.(); + }, 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, @@ -63,11 +88,22 @@ export class SmtpEmailProvider implements EmailProvider { : 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); + transport.close?.(); } } } 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 index 00490ce7..e6f5bcf8 100644 --- a/apps/api/src/modules/booking-request/booking-request-mailer.service.ts +++ b/apps/api/src/modules/booking-request/booking-request-mailer.service.ts @@ -150,21 +150,27 @@ export class BookingRequestMailerService { deliveryId: string, bookingRequestId: string, propertyId: string, - mode: DeliveryMode = 'automatic', - actor?: AuditActor, ): Promise { - const claimed = await this.claim(deliveryId, bookingRequestId, propertyId, mode, actor); + 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.sendWithTimeout({ + 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); } @@ -174,23 +180,26 @@ export class BookingRequestMailerService { bookingRequestId: string, propertyId: string, actor: AuditActor, - ): Promise { - await this.db.transaction(async (tx) => { + ): 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 requeuedAt = new Date(); - const [requeued] = await tx + const claimedAt = new Date(); + const leaseUntil = new Date(claimedAt.getTime() + CLAIM_LEASE_MS); + const [manualClaim] = await tx .update(bookingRequestEmailDeliveries) .set({ - status: 'pending', + status: 'processing', + attempts: row.attempts + 1, automaticAttempts: 0, - claimedAt: null, - nextAttemptAt: requeuedAt, + claimedAt, + nextAttemptAt: leaseUntil, + lastAttemptAt: claimedAt, errorMessage: null, providerMessageId: null, - updatedAt: requeuedAt, + updatedAt: claimedAt, }) .where(and( eq(bookingRequestEmailDeliveries.id, deliveryId), @@ -199,27 +208,26 @@ export class BookingRequestMailerService { eq(bookingRequestEmailDeliveries.status, 'failed'), )) .returning(); - if (!requeued) throw new ConflictException('Email delivery retry state changed'); + if (!manualClaim) throw new ConflictException('Email delivery retry state changed'); await tx.insert(auditLogs).values({ propertyId, action: 'update', entityType: 'booking_request_email_delivery', entityId: deliveryId, ...actorFields(actor), - previousValue: { status: 'failed' }, - newValue: { status: 'pending', automaticAttempts: 0 }, - description: 'Booking request email manually requeued', + previousValue: { status: 'failed', attempts: row.attempts }, + newValue: { + status: 'processing', + attempts: manualClaim.attempts, + automaticAttempts: 0, + mode: 'manual', + }, + description: 'Booking request email delivery attempted', }); + return manualClaim; }); - const result = await this.deliver( - deliveryId, - bookingRequestId, - propertyId, - 'manual', - actor, - ); - return result ? this.toView(result) : undefined; + return this.toView(await this.deliverClaimed(claimed, 'manual', actor)); } async deliverForRequestBestEffort( @@ -279,15 +287,12 @@ export class BookingRequestMailerService { deliveryId: string, bookingRequestId: string, propertyId: string, - mode: DeliveryMode, - actor?: AuditActor, ): 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 (mode === 'automatic' && !this.isAutomaticallyEligible(row, now)) return undefined; - if (mode === 'manual' && row.status !== 'pending') return undefined; + if (!this.isAutomaticallyEligible(row, now)) return undefined; const leaseUntil = new Date(now.getTime() + CLAIM_LEASE_MS); const [claimed] = await tx @@ -295,7 +300,7 @@ export class BookingRequestMailerService { .set({ status: 'processing', attempts: row.attempts + 1, - automaticAttempts: row.automaticAttempts + (mode === 'automatic' ? 1 : 0), + automaticAttempts: row.automaticAttempts + 1, claimedAt: now, nextAttemptAt: leaseUntil, lastAttemptAt: now, @@ -318,7 +323,7 @@ export class BookingRequestMailerService { action: 'update', entityType: 'booking_request_email_delivery', entityId: deliveryId, - ...actorFields(actor), + ...actorFields(), previousValue: { status: row.status, attempts: row.attempts }, newValue: { status: 'processing', @@ -339,6 +344,7 @@ export class BookingRequestMailerService { ): Promise { const finishedAt = new Date(); const shouldRetry = !transportResult.sent + && !transportResult.outcomeUnknown && mode === 'automatic' && claimed.automaticAttempts < MAX_AUTOMATIC_ATTEMPTS; const status: Delivery['status'] = transportResult.sent @@ -347,7 +353,11 @@ export class BookingRequestMailerService { const nextAttemptAt = shouldRetry ? new Date(finishedAt.getTime() + this.backoffMs(claimed.automaticAttempts)) : null; - const errorMessage = transportResult.sent ? null : 'Email transport failed'; + 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 @@ -402,26 +412,6 @@ export class BookingRequestMailerService { }); } - private async sendWithTimeout( - message: Parameters[0], - ): Promise { - let timeout: ReturnType | undefined; - try { - return await Promise.race([ - this.emailService.send(message).catch(() => ({ sent: false } as EmailResult)), - new Promise((resolve) => { - timeout = setTimeout( - () => resolve({ sent: false, error: 'Email transport timed out' }), - SEND_TIMEOUT_MS, - ); - timeout.unref?.(); - }), - ]); - } finally { - if (timeout) clearTimeout(timeout); - } - } - private isAutomaticallyEligible( delivery: Delivery, now: Date, 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 index 61ef91dd..d4c326bf 100644 --- a/apps/api/src/modules/booking-request/booking-request-mailer.spec.ts +++ b/apps/api/src/modules/booking-request/booking-request-mailer.spec.ts @@ -64,6 +64,7 @@ function createHarness(seed: Delivery[] = [], options: HarnessOptions = {}) { requests: [{ id: REQUEST_ID, propertyId: PROPERTY_ID }], deliveries: seed.map((row) => ({ ...row })), audits: [] as Array>, + deliveryUpdates: [] as Array>, }; let remainingAuditFailures = options.failAuditTimes ?? 0; @@ -140,12 +141,14 @@ function createHarness(seed: Delivery[] = [], options: HarnessOptions = {}) { 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); @@ -161,11 +164,17 @@ function createHarness(seed: Delivery[] = [], options: HarnessOptions = {}) { 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; } }, @@ -408,7 +417,7 @@ describe('BookingRequestMailerService', () => { ); }); - it('manually requeues a terminal failure with attributed transactional audits', async () => { + 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', @@ -421,25 +430,46 @@ describe('BookingRequestMailerService', () => { nextAttemptAt: null, errorMessage: 'Email transport failed', })]); - h.emailService.send.mockResolvedValue({ - sent: true, provider: 'smtp', messageId: 'provider-retry-id', + 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(); - const retried = await h.service.retry(DELIVERY_ID, REQUEST_ID, PROPERTY_ID, actor); + 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 manually requeued', 'Booking request email delivery attempted', 'Booking request email delivered', ]); - expect(h.state.audits).toEqual(expect.arrayContaining([ - expect.objectContaining(actor), - ])); + 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 requeue when its attributed audit cannot be persisted', async () => { + it('rolls back a manual claim when its attributed audit cannot be persisted', async () => { const h = createHarness([delivery({ status: 'failed', attempts: 5, @@ -447,7 +477,7 @@ describe('BookingRequestMailerService', () => { nextAttemptAt: null, errorMessage: 'Email transport failed', })], { - failAuditDescription: 'Booking request email manually requeued', + failAuditDescription: 'Booking request email delivery attempted', failAuditTimes: 1, }); @@ -461,6 +491,71 @@ describe('BookingRequestMailerService', () => { 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', @@ -494,20 +589,48 @@ describe('BookingRequestMailerService', () => { await expect(first).resolves.toMatchObject({ status: 'sent' }); }); - it('bounds a hung transport below the lease and schedules recovery', async () => { + 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()]); - h.emailService.send.mockReturnValue(new Promise(() => undefined)); + 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: 'pending', + status: 'failed', claimedAt: null, - nextAttemptAt: new Date('2026-08-25T00:01:30.000Z'), + nextAttemptAt: null, + errorMessage: 'Email delivery outcome requires manual review', }); + expect(providerSettled).toBe(true); }); it('reuses stable transport identity after an ambiguous committed send', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb3b9352..55d5c7b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: jwks-rsa: specifier: ^4.0.1 version: 4.0.1 + nodemailer: + specifier: ^9.0.5 + version: 9.0.5 passport: specifier: ^0.7.0 version: 0.7.0 @@ -4174,6 +4177,10 @@ packages: node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + nodemailer@9.0.5: + resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -9143,6 +9150,8 @@ snapshots: node-releases@2.0.37: {} + nodemailer@9.0.5: {} + normalize-path@3.0.0: {} normalize-url@8.1.1: {} From 3d5b08081129014dfba7474a6ddcf40b30e26c77 Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 25 Aug 2026 01:42:22 +0200 Subject: [PATCH 31/87] fix(booking-requests): hard-close timed out SMTP sends --- .../providers/smtp-email.provider.spec.ts | 105 ++++++++++++++++++ .../providers/smtp-email.provider.ts | 59 +++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) 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 index 19247922..5a042fc9 100644 --- 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 @@ -54,4 +54,109 @@ describe('SmtpEmailProvider bounded send', () => { }); } }); + + 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 821d9607..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 @@ -10,6 +10,30 @@ import { 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. */ @@ -63,15 +87,24 @@ export class SmtpEmailProvider implements EmailProvider { 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; - transport.close?.(); + 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 { @@ -103,7 +136,27 @@ export class SmtpEmailProvider implements EmailProvider { return { sent: false, provider: this.name, error: error.message }; } finally { clearTimeout(timeout); - transport.close?.(); + 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?.(); } } } From fc3158561a9179d40f2f076ef84ba0c3fc9d69f0 Mon Sep 17 00:00:00 2001 From: Agus Date: Tue, 25 Aug 2026 02:07:14 +0200 Subject: [PATCH 32/87] feat(booking-widget): submit booking requests --- apps/booking/src/App.tsx | 6 + apps/booking/src/api/client.ts | 21 + apps/booking/src/api/types.ts | 65 +++ apps/booking/src/components/Button.tsx | 2 +- .../src/components/ConfiguredQuestion.tsx | 128 ++++++ apps/booking/src/components/Field.tsx | 2 +- apps/booking/src/components/Layout.tsx | 26 +- .../src/components/RequestStayDocket.tsx | 99 +++++ .../src/components/StripeSetupForm.tsx | 96 +++++ .../src/context/BookingFlowContext.tsx | 129 ++++-- apps/booking/src/index.css | 11 + apps/booking/src/lib/requestPayload.ts | 53 +++ apps/booking/src/pages/Extras.tsx | 10 +- .../src/pages/RequestApplication.test.tsx | 402 ++++++++++++++++++ apps/booking/src/pages/RequestApplication.tsx | 259 +++++++++++ .../booking/src/pages/RequestPayment.test.tsx | 278 ++++++++++++ apps/booking/src/pages/RequestPayment.tsx | 282 ++++++++++++ apps/booking/src/pages/RequestReceived.tsx | 53 +++ 18 files changed, 1877 insertions(+), 45 deletions(-) create mode 100644 apps/booking/src/components/ConfiguredQuestion.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/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/RequestPayment.test.tsx create mode 100644 apps/booking/src/pages/RequestPayment.tsx create mode 100644 apps/booking/src/pages/RequestReceived.tsx diff --git a/apps/booking/src/App.tsx b/apps/booking/src/App.tsx index 55ea9ee7..a857bd0c 100644 --- a/apps/booking/src/App.tsx +++ b/apps/booking/src/App.tsx @@ -8,6 +8,9 @@ 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'; export default function App() { return ( @@ -20,6 +23,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> 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..fe031b0f 100644 --- a/apps/booking/src/api/types.ts +++ b/apps/booking/src/api/types.ts @@ -17,6 +17,29 @@ export interface Branding { accentColor?: string | null; } +export type BookingMode = 'instant' | 'request'; +export type PaymentMethodCollection = 'required' | 'optional' | 'disabled'; +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 +50,9 @@ export interface BookingConfig { stripePublishableKey?: string | null; sellableRoomTypeIds: string[]; sellableRatePlanIds: string[]; + bookingMode: BookingMode; + paymentMethodCollection: PaymentMethodCollection; + formQuestions: BookingFormQuestion[]; } // --- Search --- @@ -167,6 +193,45 @@ export interface BookResponse { cancellationPolicy: string; } +// --- Request to book --- + +export interface RequestPaymentMethodSetupRequest { + guestEmail: string; + idempotencyKey: string; +} + +export interface RequestPaymentMethodSetupResponse { + setupIntentId: string; + clientSecret: string; +} + +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..2cb76439 --- /dev/null +++ b/apps/booking/src/components/ConfiguredQuestion.tsx @@ -0,0 +1,128 @@ +import type { + BookingApplicationAnswer, + BookingFormQuestion, +} from '../api/types'; +import { Field, inputClass } from './Field'; + +interface ConfiguredQuestionProps { + question: BookingFormQuestion; + value?: BookingApplicationAnswer; + onChange: (value?: BookingApplicationAnswer) => void; +} + +export function ConfiguredQuestion({ + question, + value, + onChange, +}: ConfiguredQuestionProps) { + const id = `request-question-${question.id}`; + const textValue = typeof value === 'string' ? value : ''; + + if (question.type === 'long_text') { + return ( + +