diff --git a/.env.example b/.env.example
index c6fbbb8a..7e85e469 100644
--- a/.env.example
+++ b/.env.example
@@ -87,6 +87,12 @@ STRIPE_MODE=mock
# REDSYS_TERMINAL=001
# REDSYS_SECRET_KEY=sq7HjrUOBfKmC576ILgskD5srU870gJ7
# REDSYS_ENV=test
+# Exact allowed origins for the pages embedding the Redsys booking widget.
+# Required for hosted booking checkout; no wildcard. HTTPS in production.
+# BOOKING_RETURN_ORIGINS=https://hotel.example,https://www.hotel.example
+# Local development can explicitly allow http://localhost:5174.
+# Public API base shared by Redsys notifications and the compact browser-return relay.
+# Use the externally reachable HTTPS base in production; provider return URLs must fit 250 characters.
# PUBLIC_API_BASE_URL=http://localhost:3000
# Guest SMS / messaging (see docs/integrations/messaging-infobip-vonage-telegram.md)
@@ -131,7 +137,8 @@ STORAGE_DRIVER=local
# S3_FORCE_PATH_STYLE=true # true for MinIO; false for AWS S3
# S3_PUBLIC_BASE_URL= # optional CDN/public base for object URLs
-# Migration source-PMS credential vault (AES-256-GCM at rest)
+# Protected credential key ring: source-PMS migration credentials and per-property
+# Redsys signing keys (AES-256-GCM at rest). Required before saving Redsys credentials.
# Generate: openssl rand -hex 32
# MIGRATION_CREDENTIAL_ENCRYPTION_KEY=
# MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID=default
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5b83a820..0754d7a1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -85,6 +85,7 @@ jobs:
run: node scripts/sync-test-count.mjs --check
env:
DATABASE_URL: postgresql://haip:haip@localhost:5432/haip_test
+ REDSYS_TEST_DATABASE_URL: postgresql://haip:haip@localhost:5432/haip_test
REDIS_URL: redis://localhost:6379
FORCE_COLOR: '0'
CI: 'true'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f6335993..2ba540fb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -71,6 +71,7 @@ jobs:
run: pnpm test
env:
DATABASE_URL: postgresql://haip:haip@localhost:5432/haip_test
+ REDSYS_TEST_DATABASE_URL: postgresql://haip:haip@localhost:5432/haip_test
REDIS_URL: redis://localhost:6379
ci-booking-requests:
diff --git a/README.md b/README.md
index bb1a6842..8d9f2744 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire
| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) |
| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
| Package Manager | pnpm workspaces | Monorepo management |
-| Testing | Vitest (2261 passing tests across 273 files with passing tests) | Unit and integration tests |
+| Testing | Vitest (2392 passing tests across 280 files with passing tests) | Unit and integration tests |
| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Containers | Docker + docker-compose | Local dev and production deployment |
| CI/CD | GitHub Actions | Automated testing, builds, and releases |
@@ -648,7 +648,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests
```bash
-# Passing-test count: 2261 test cases across 273 files (skipped excluded)
+# Passing-test count: 2392 test cases across 280 files (skipped excluded)
# API tests only
pnpm --filter @telivityhaip/api test
@@ -1197,12 +1197,61 @@ HAIP is built in public and contributions are welcome.
pnpm install # Install dependencies
pnpm build # Build all workspace packages
pnpm dev # Start API in dev mode (hot reload)
-pnpm test # Run all tests (2261 passing, 273 files with passes; skipped excluded)
+pnpm test # Run all tests (2392 passing, 280 files with passes; skipped excluded)
pnpm lint # ESLint
```
---
+## Hosted booking payment returns
+
+For Redsys booking checkout, configure `BOOKING_RETURN_ORIGINS` with the exact
+origins of the pages embedding the booking widget (comma-separated, no wildcard).
+For example: `https://hotel.example,https://www.hotel.example`. Production requires
+HTTPS. Local development can explicitly allow `http://localhost:5174`. Configure
+the corresponding CORS origins for cross-origin widgets as usual.
+
+Run `pnpm db:migrate` before deploying the API changes, including
+`0024_payment_authorization_finalization.sql`,
+`0025_payment_booking_return_reference.sql`, and
+`0026_payment_booking_return_destination.sql`. Follow the
+[Redsys credential migration](docs/integrations/payments-redsys.md#existing-installations)
+before accepting payments with previously stored signing keys.
+The widget sends its full embedding-page URL as
+`returnUrl` to `POST /api/v1/booking-engine/book`. The API validates the origin
+before creating a guest or reservation, preserves the host path/query/fragment,
+and binds both provider outcomes to the same randomly generated return reference.
+The booking endpoint no longer accepts separate browser success/failure URLs.
+
+Both provider URLs use the compact API relay
+`/api/v1/booking-return/:reference?propertyId=:propertyId`, including for short
+hotel URLs. It shares the `PUBLIC_API_BASE_URL` configuration used by Redsys
+notifications (with the existing `API_BASE_URL` fallback). The server checks the
+provider's 250-character URL limit before creating booking records. Full hotel
+URLs are stored without the capability and are never truncated. The relay looks
+up the capability hash together with the supplied tenant scope, checks expiry and
+the destination allowlist again, and issues a non-cacheable 303 to that saved
+page with the original reference appended. It accepts no redirect destination or
+payment outcome from the browser.
+
+On return, the widget boots directly into its payment-status view without saved
+router state or browser storage. It polls
+`GET /api/v1/booking-engine/payment-return-status`, using the normal
+`x-booking-key` and an `x-payment-return-reference` header. The response contains
+only `status`: `processing`, `succeeded`, `failed`, `cancelled`, or `unavailable`.
+Only server payment state determines the result; browser flags are ignored.
+`succeeded` reports payment authorization, not unconditional booking confirmation.
+The reference expires seven days after payment creation and cannot retrieve guest
+details, retrieve a confirmation credential, or cancel a booking.
+
+Keep return references out of application, proxy, and analytics logs; the relay
+path reference and the host page's `haip_payment_return` query parameter are
+limited bearer capabilities. The relay sets `Referrer-Policy: no-referrer`.
+Failed, cancelled, or unverified returns direct guests to contact the hotel before
+trying again, because an earlier booking or payment may already exist.
+
+---
+
## License
Licensed under the [Apache License, Version 2.0](LICENSE).
diff --git a/apps/api/src/modules/booking-engine/booking-engine.controller.ts b/apps/api/src/modules/booking-engine/booking-engine.controller.ts
index b1766a91..51538eec 100644
--- a/apps/api/src/modules/booking-engine/booking-engine.controller.ts
+++ b/apps/api/src/modules/booking-engine/booking-engine.controller.ts
@@ -7,8 +7,10 @@ import {
Param,
Req,
UseGuards,
+ Header,
+ Headers,
} from '@nestjs/common';
-import { ApiTags, ApiOperation, ApiResponse, ApiSecurity } from '@nestjs/swagger';
+import { ApiTags, ApiOperation, ApiResponse, ApiSecurity, ApiHeader } from '@nestjs/swagger';
import { Public } from '../auth/public.decorator';
import { BookingKeyGuard } from '../auth/booking-key.guard';
import { BookingEngineScopeGuard } from '../auth/booking-engine-scope.guard';
@@ -79,19 +81,6 @@ export class BookingEngineController {
return this.service.book(this.propertyId(req), dto);
}
-
- @Get('checkouts/:checkoutToken')
- @ApiOperation({
- summary:
- 'Recover booking/payment state after a Redsys hosted-checkout browser return',
- })
- async getCheckout(
- @Param('checkoutToken') checkoutToken: string,
- @Req() req: any,
- ) {
- return this.service.getCheckout(this.propertyId(req), checkoutToken);
- }
-
@Get('bookings/:confirmationNumber')
@ApiOperation({ summary: 'Retrieve a booking by confirmation number (guest self-service)' })
async getBooking(@Param('confirmationNumber') confirmationNumber: string) {
@@ -99,6 +88,19 @@ export class BookingEngineController {
return this.service.verify(confirmationNumber);
}
+ @Get('payment-return-status')
+ @Header('Cache-Control', 'no-store')
+ @ApiHeader({ name: 'x-payment-return-reference', required: true })
+ @ApiOperation({ summary: 'Read payment state using a limited, expiring return reference' })
+ @ApiResponse({ status: 200, schema: { type: 'object', required: ['status'], properties: {
+ status: { type: 'string', enum: ['processing', 'succeeded', 'failed', 'cancelled', 'unavailable'] },
+ } } })
+ @ApiResponse({ status: 404, description: 'Unknown, expired, or out-of-scope return reference' })
+ async paymentReturnStatus(@Headers('x-payment-return-reference') reference: string, @Req() req: any) {
+ // Tenant comes from the booking-key credential, not from the return reference.
+ return this.service.paymentReturnStatus(this.propertyId(req), reference);
+ }
+
@Delete('bookings/:confirmationNumber')
@ApiOperation({ summary: 'Cancel a booking by confirmation number' })
async cancel(
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 2fc49fb2..67e84b97 100644
--- a/apps/api/src/modules/booking-engine/booking-engine.module.ts
+++ b/apps/api/src/modules/booking-engine/booking-engine.module.ts
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { BookingEngineController } from './booking-engine.controller';
import { BookingEngineAdminController } from './booking-engine-admin.controller';
+import { BookingReturnController } from './booking-return.controller';
import { BookingEngineService } from './booking-engine.service';
import { BookingEngineConfigService } from './booking-engine-config.service';
import { BookingThrottleGuard } from './booking-throttle.guard';
@@ -32,7 +33,7 @@ import { PolicyModule } from '../policy/policy.module';
AncillaryModule,
PolicyModule,
],
- controllers: [BookingEngineController, BookingEngineAdminController],
+ controllers: [BookingEngineController, BookingEngineAdminController, BookingReturnController],
providers: [
BookingEngineService,
BookingEngineConfigService,
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 58217250..b020ec24 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
@@ -40,7 +40,10 @@ function makeService(overrides: Partial> = {}) {
cancel: vi.fn(),
};
const folio = { createAutoFolio: vi.fn().mockResolvedValue({ id: 'folio-1' }) };
- const payment = { authorizePayment: vi.fn().mockResolvedValue({ id: 'pay-1' }) };
+ const payment = {
+ assertAuthorizationAvailable: vi.fn().mockResolvedValue(undefined),
+ authorizePayment: vi.fn().mockResolvedValue({ id: 'pay-1', status: 'authorized' }),
+ };
const deposit = { recordDeposit: vi.fn().mockResolvedValue({ id: 'dep-1', status: 'held' }) };
const search = { search: vi.fn() };
const bookingSvc = { verify: vi.fn() };
@@ -111,6 +114,52 @@ const bookDto = {
};
describe('BookingEngineService.quote', () => {
+ it('rejects unavailable Redsys credentials before any provisional booking writes', async () => {
+ const { svc, runtimeConfig, payment, guest, reservation, folio, deposit } = makeService();
+ runtimeConfig.get.mockImplementation((key: string) => ({ PAYMENT_GATEWAY: 'redsys', BOOKING_RETURN_ORIGINS: 'https://hotel.example' })[key]);
+ payment.assertAuthorizationAvailable.mockRejectedValue(new BadRequestException('Redsys credentials are not configured'));
+ await expect(svc.book(PROP, { ...bookDto, returnUrl: 'https://hotel.example/book' } as any)).rejects.toThrow(/credentials/);
+ expect(guest.create).not.toHaveBeenCalled();
+ expect(reservation.create).not.toHaveBeenCalled();
+ expect(folio.createAutoFolio).not.toHaveBeenCalled();
+ expect(deposit.recordDeposit).not.toHaveBeenCalled();
+ });
+ it('rejects an untrusted Redsys return before creating a guest or reservation', async () => {
+ const { svc, runtimeConfig, guest, reservation } = makeService();
+ runtimeConfig.get.mockImplementation((key: string) => key === 'PAYMENT_GATEWAY' ? 'redsys' : undefined);
+ await expect(svc.book(PROP, { ...bookDto, returnUrl: 'https://attacker.example/book' } as any)).rejects.toThrow(/return/i);
+ expect(guest.create).not.toHaveBeenCalled();
+ expect(reservation.create).not.toHaveBeenCalled();
+ });
+
+ it('binds the hosted return reference before signing the redirect and persists only its hash', async () => {
+ const { svc, runtimeConfig, payment } = makeService();
+ runtimeConfig.get.mockImplementation((key: string) => ({ PAYMENT_GATEWAY: 'redsys', BOOKING_RETURN_ORIGINS: 'https://hotel.example', PUBLIC_API_BASE_URL: 'https://api.example' })[key]);
+ const destination = `https://hotel.example/booking?lang=es&context=${'a'.repeat(500)}`;
+ await svc.book(PROP, { ...bookDto, returnUrl: destination } as any);
+ const [dto, , options] = payment.authorizePayment.mock.calls[0] as any[];
+ expect(dto.redirectUrlOk).toBe(dto.redirectUrlKo);
+ const url = new URL(dto.redirectUrlOk);
+ expect(url.origin).toBe('https://api.example');
+ expect(url.href.length).toBeLessThanOrEqual(250);
+ expect(url.pathname).toMatch(/^\/api\/v1\/booking-return\/[A-Za-z0-9_-]{43}$/);
+ expect(url.searchParams.get('propertyId')).toBe(PROP);
+ expect(options.returnReferenceHash).toMatch(/^[a-f0-9]{64}$/);
+ expect(options.returnDestination).toBe(destination);
+ });
+
+ it('rejects an oversized relay configuration before creating booking records', async () => {
+ const { svc, runtimeConfig, guest, reservation, payment } = makeService();
+ runtimeConfig.get.mockImplementation((key: string) => ({
+ PAYMENT_GATEWAY: 'redsys', BOOKING_RETURN_ORIGINS: 'https://hotel.example',
+ PUBLIC_API_BASE_URL: `https://api.example/${'a'.repeat(250)}`,
+ })[key]);
+ await expect(svc.book(PROP, { ...bookDto, returnUrl: 'https://hotel.example/book' } as any)).rejects.toThrow(/250/);
+ expect(guest.create).not.toHaveBeenCalled();
+ expect(reservation.create).not.toHaveBeenCalled();
+ expect(payment.authorizePayment).not.toHaveBeenCalled();
+ });
+
it('prices server-side with the real tax engine and computes the deposit', async () => {
const { svc } = makeService();
const q = await svc.quote(PROP, { roomTypeId: RT, ratePlanId: RP, checkIn: '2026-07-01', checkOut: '2026-07-03', adults: 2 });
@@ -244,6 +293,34 @@ describe('BookingEngineService.quote', () => {
});
describe('BookingEngineService.book', () => {
+ it('does not classify an unauthorised payment as held when no next action is provided', async () => {
+ const { svc, config, payment, deposit, reservation } = makeService();
+ config.getConfig.mockResolvedValue({ autoConfirm: true });
+ payment.authorizePayment.mockResolvedValue({ id: 'pay-1', status: 'pending' });
+ const result = await svc.book(PROP, bookDto as any);
+ expect(deposit.recordDeposit).not.toHaveBeenCalled();
+ expect(reservation.confirm).not.toHaveBeenCalled();
+ expect(result.deposit?.status).toBe('pending');
+ });
+
+ it('keeps a redirect payment pending without recording a deposit or confirming', async () => {
+ const { svc, config, payment, deposit, reservation } = makeService();
+ config.getConfig.mockResolvedValue({ autoConfirm: true });
+ payment.authorizePayment.mockResolvedValue({
+ id: 'pay-1', status: 'pending', nextAction: { type: 'redirect' },
+ });
+
+ const result = await svc.book(PROP, bookDto as any);
+
+ expect(deposit.recordDeposit).not.toHaveBeenCalled();
+ expect(reservation.confirm).not.toHaveBeenCalled();
+ expect(result.status).toBe('pending');
+ expect(result.deposit).toMatchObject({ status: 'pending_redirect' });
+ expect(payment.authorizePayment).toHaveBeenCalledWith(expect.anything(), {
+ deposit: { reservationId: 'res-1', isRefundable: true, autoConfirm: true },
+ }, undefined);
+ });
+
it('classifies the payment as a held deposit', async () => {
const { svc, deposit, payment } = makeService();
const res = await svc.book(PROP, bookDto as any);
@@ -313,32 +390,23 @@ describe('BookingEngineService.book', () => {
await expect(svc.book(PROP, bookDto as any)).rejects.toBeInstanceOf(ForbiddenException);
});
-
- it('defers deposit + auto-confirm while Redsys redirect is pending', async () => {
- const { svc, deposit, payment, reservation, config } = makeService();
- config.getConfig.mockResolvedValue({ autoConfirm: true });
- payment.authorizePayment.mockResolvedValue({
- id: 'pay-1',
- gatewayTransactionId: '1234ABCDEF',
- nextAction: {
- type: 'redirect',
- url: 'https://sis-t.redsys.es/realizarPago',
- method: 'POST',
- formFields: { Ds_SignatureVersion: 'HMAC_SHA512_V2' },
- },
+ 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 },
});
- const res = await svc.book(PROP, bookDto as any);
-
- expect(deposit.recordDeposit).not.toHaveBeenCalled();
- expect(reservation.confirm).not.toHaveBeenCalled();
- expect(res.deposit).toMatchObject({
- paymentId: 'pay-1',
- status: 'pending_redirect',
- checkoutToken: '1234ABCDEF',
- });
- expect(res.deposit?.nextAction).toMatchObject({ type: 'redirect' });
- expect(res.status).toBe('pending');
+ 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 () => {
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 344b512a..1bf3f858 100644
--- a/apps/api/src/modules/booking-engine/booking-engine.service.ts
+++ b/apps/api/src/modules/booking-engine/booking-engine.service.ts
@@ -1,14 +1,8 @@
-import { Injectable, BadRequestException, ForbiddenException, NotFoundException, Inject } from '@nestjs/common';
+import { Injectable, BadRequestException, ForbiddenException, Inject } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and } from 'drizzle-orm';
import Decimal from 'decimal.js';
-import {
- bookings,
- depositLedgerEntries,
- folios,
- payments,
- reservations,
-} from '@telivityhaip/database';
+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';
@@ -29,6 +23,7 @@ import { DepositService } from '../accounting/deposit.service';
import { AncillaryService } from '../ancillary/ancillary.service';
import { PolicyService } from '../policy/policy.service';
import { BookingEngineConfigService } from './booking-engine-config.service';
+import { BookingReturnService } from './booking-return.service';
import type { BeSearchDto } from './dto/be-search.dto';
import type { BeQuoteDto } from './dto/be-quote.dto';
import type { BeCreateBookingDto } from './dto/be-create-booking.dto';
@@ -433,6 +428,14 @@ export class BookingEngineService {
if (depositDue.greaterThan(0) && !dto.paymentToken) {
throw new BadRequestException('A payment is required to confirm this booking');
}
+ const provider = resolvePaymentGatewayProvider(this.runtimeConfig);
+ // Validate before guest/reservation writes; browser URLs never authorize payment.
+ const bookingReturn = provider === 'redsys' && depositDue.greaterThan(0)
+ ? new BookingReturnService(this.db, this.runtimeConfig).prepare(propertyId, dto.returnUrl)
+ : undefined;
+ if (bookingReturn) {
+ await this.paymentService.assertAuthorizationAvailable(propertyId, provider, quote.depositDue, quote.currencyCode);
+ }
// 2. Guest — walk-in exception (no prior reservation; one is created next).
// We intentionally do NOT do an unscoped email lookup (cross-tenant PII leak).
@@ -498,10 +501,9 @@ export class BookingEngineService {
amount: string;
status: string;
nextAction?: unknown;
- checkoutToken?: string | null;
} | null = null;
if (depositDue.greaterThan(0) && dto.paymentToken) {
- const provider = resolvePaymentGatewayProvider(this.runtimeConfig);
+ const policy = config.depositPolicy as DepositPolicy;
const payment = await this.paymentService.authorizePayment({
folioId: folio.id,
propertyId,
@@ -511,15 +513,20 @@ export class BookingEngineService {
gatewayPaymentToken: dto.paymentToken,
cardLastFour: dto.cardLastFour,
cardBrand: dto.cardBrand,
- redirectUrlOk: dto.redirectUrlOk,
- redirectUrlKo: dto.redirectUrlKo,
- } as any);
-
- const policy = config.depositPolicy as DepositPolicy;
- // Hosted redirect (Redsys): do NOT record a deposit until the signed
- // MerchantURL notification authorizes the payment. Otherwise cancel /
- // abandon leaves a held liability for money that never cleared.
- if (!payment.nextAction) {
+ redirectUrlOk: bookingReturn?.url,
+ redirectUrlKo: bookingReturn?.url,
+ } as any, {
+ deposit: {
+ reservationId: reservation.id,
+ isRefundable: policy.refundable,
+ autoConfirm: config.isEnabled && await this.shouldAutoConfirm(propertyId),
+ },
+ }, bookingReturn ? { returnReferenceHash: bookingReturn.referenceHash, returnDestination: bookingReturn.destination } : undefined);
+
+ // A redirect is still awaiting authorization; its saved intent is finalized
+ // by the verified provider notification. Synchronous gateways keep this path.
+ const authorized = payment.status === 'authorized' || payment.status === 'captured';
+ if (authorized) {
await this.depositService.recordDeposit({
propertyId,
reservationId: reservation.id,
@@ -533,13 +540,8 @@ export class BookingEngineService {
depositInfo = {
paymentId: payment.id,
amount: depositDue.toFixed(2),
- status: payment.nextAction ? 'pending_redirect' : 'held',
- ...(payment.nextAction
- ? {
- nextAction: payment.nextAction,
- checkoutToken: payment.gatewayTransactionId,
- }
- : {}),
+ status: authorized ? 'held' : payment.nextAction ? 'pending_redirect' : payment.status,
+ ...(payment.nextAction ? { nextAction: payment.nextAction } : {}),
};
}
@@ -549,7 +551,7 @@ export class BookingEngineService {
if (
config.isEnabled &&
depositInfo &&
- depositInfo.status !== 'pending_redirect' &&
+ depositInfo.status === 'held' &&
(await this.shouldAutoConfirm(propertyId))
) {
const confirmed = await this.reservationService.confirm(reservation.id, propertyId);
@@ -575,6 +577,14 @@ export class BookingEngineService {
// --- Retrieve / cancel (ownership already enforced by BookingEngineScopeGuard) ---
+ async paymentReturnStatus(propertyId: string, reference: string) {
+ return new BookingReturnService(this.db, this.runtimeConfig).status(propertyId, reference);
+ }
+
+ async resolvePaymentReturn(propertyId: string, reference: string) {
+ return new BookingReturnService(this.db, this.runtimeConfig).resolve(propertyId, reference);
+ }
+
async verify(confirmationNumber: string) {
return this.bookingService.verify(confirmationNumber);
}
@@ -672,85 +682,6 @@ export class BookingEngineService {
}
}
-
- /**
- * Recover booking/payment state after a Redsys hosted-checkout return.
- * `checkoutToken` is the Redsys order id (gatewayTransactionId) embedded in
- * URLOK/URLKO — opaque to the guest and durable across MemoryRouter remounts.
- */
- async getCheckout(propertyId: string, checkoutToken: string) {
- const token = checkoutToken?.trim();
- if (!token) {
- throw new NotFoundException('Checkout not found');
- }
-
- const [payment] = await this.db
- .select()
- .from(payments)
- .where(
- and(
- eq(payments.gatewayTransactionId, token),
- eq(payments.gatewayProvider, 'redsys'),
- eq(payments.propertyId, propertyId),
- ),
- )
- .limit(1);
-
- if (!payment?.folioId) {
- throw new NotFoundException('Checkout not found');
- }
-
- const [folio] = await this.db
- .select()
- .from(folios)
- .where(
- and(eq(folios.id, payment.folioId), eq(folios.propertyId, propertyId)),
- )
- .limit(1);
-
- if (!folio?.reservationId) {
- throw new NotFoundException('Checkout not found');
- }
-
- const [reservation] = await this.db
- .select()
- .from(reservations)
- .where(
- and(
- eq(reservations.id, folio.reservationId),
- eq(reservations.propertyId, propertyId),
- ),
- )
- .limit(1);
-
- if (!reservation) {
- throw new NotFoundException('Checkout not found');
- }
-
- const [deposit] = await this.db
- .select()
- .from(depositLedgerEntries)
- .where(
- and(
- eq(depositLedgerEntries.paymentId, payment.id),
- eq(depositLedgerEntries.propertyId, propertyId),
- ),
- )
- .limit(1);
-
- return {
- checkoutToken: token,
- confirmationNumber: reservation.confirmationNumber,
- reservationId: reservation.id,
- reservationStatus: reservation.status,
- paymentId: payment.id,
- paymentStatus: payment.status,
- depositStatus: deposit?.status ?? null,
- amount: String(payment.amount),
- currencyCode: payment.currencyCode,
- };
- }
-
private async shouldAutoConfirm(propertyId: string): Promise {
const cfg = await this.bookingEngineConfig.getConfig(propertyId);
return cfg.autoConfirm === true;
diff --git a/apps/api/src/modules/booking-engine/booking-return.controller.spec.ts b/apps/api/src/modules/booking-engine/booking-return.controller.spec.ts
new file mode 100644
index 00000000..fff43136
--- /dev/null
+++ b/apps/api/src/modules/booking-engine/booking-return.controller.spec.ts
@@ -0,0 +1,53 @@
+import { Test } from '@nestjs/testing';
+import { NotFoundException, type INestApplication } from '@nestjs/common';
+import request from 'supertest';
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { BookingReturnController } from './booking-return.controller';
+import { BookingEngineService } from './booking-engine.service';
+
+const PROPERTY = '11111111-1111-4111-a111-111111111111';
+const reference = 'a'.repeat(43);
+describe('Public booking return relay HTTP boundary', () => {
+ let app: INestApplication;
+ const resolvePaymentReturn = vi.fn();
+ beforeAll(async () => {
+ const module = await Test.createTestingModule({
+ controllers: [BookingReturnController],
+ providers: [{ provide: BookingEngineService, useValue: { resolvePaymentReturn } }],
+ }).compile();
+ app = module.createNestApplication();
+ app.setGlobalPrefix('api/v1');
+ await app.init();
+ });
+ beforeEach(() => { resolvePaymentReturn.mockReset(); });
+ afterAll(async () => { await app?.close(); });
+
+ it('redirects without browser credentials to only the persisted target and prevents caching/referrer leakage', async () => {
+ const destination = `https://hotel.example/stays/book?haip_payment_return=${reference}`;
+ resolvePaymentReturn.mockResolvedValue(destination);
+ const response = await request(app.getHttpServer()).get(`/api/v1/booking-return/${reference}`)
+ .query({ propertyId: PROPERTY, returnUrl: 'https://attacker.example', redsys: 'ok' });
+ expect(response.status).toBe(303);
+ expect(response.headers.location).toBe(destination);
+ expect(response.headers['cache-control']).toBe('no-store');
+ expect(response.headers['referrer-policy']).toBe('no-referrer');
+ expect(resolvePaymentReturn).toHaveBeenCalledTimes(1);
+ expect(resolvePaymentReturn).toHaveBeenCalledWith(PROPERTY, reference);
+ });
+
+ it.each([undefined, 'invalid'])('requires valid request tenant scope: %s', async (propertyId) => {
+ const response = await request(app.getHttpServer()).get(`/api/v1/booking-return/${reference}`)
+ .query(propertyId ? { propertyId } : {});
+ expect(response.status).toBe(400);
+ expect(response.headers.location).toBeUndefined();
+ expect(resolvePaymentReturn).not.toHaveBeenCalled();
+ });
+
+ it('returns 404 with no redirect for unknown, expired or cross-bound capabilities', async () => {
+ resolvePaymentReturn.mockRejectedValue(new NotFoundException('Payment return not found'));
+ const response = await request(app.getHttpServer()).get(`/api/v1/booking-return/${reference}`)
+ .query({ propertyId: PROPERTY });
+ expect(response.status).toBe(404);
+ expect(response.headers.location).toBeUndefined();
+ });
+});
diff --git a/apps/api/src/modules/booking-engine/booking-return.controller.ts b/apps/api/src/modules/booking-engine/booking-return.controller.ts
new file mode 100644
index 00000000..4a055ee1
--- /dev/null
+++ b/apps/api/src/modules/booking-engine/booking-return.controller.ts
@@ -0,0 +1,32 @@
+import { Controller, Get, Header, Param, ParseUUIDPipe, Query, Redirect } from '@nestjs/common';
+import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
+import { Public } from '../auth/public.decorator';
+import { BookingEngineService } from './booking-engine.service';
+
+/**
+ * Full-page provider navigation cannot supply x-booking-key. This isolated relay
+ * is authorized by the limited opaque capability, paired with request tenant scope.
+ * It only redirects to a previously validated and persisted page; it never finalizes payment.
+ */
+@ApiTags('Booking Engine — Guest-facing Direct Booking')
+@Controller('booking-return')
+@Public()
+export class BookingReturnController {
+ constructor(private readonly service: BookingEngineService) {}
+
+ @Get(':reference')
+ @Header('Cache-Control', 'no-store')
+ @Header('Referrer-Policy', 'no-referrer')
+ @Redirect(undefined, 303)
+ @ApiOperation({ summary: 'Return from hosted checkout to the bound embedding page' })
+ @ApiParam({ name: 'reference', description: 'Opaque expiring return capability' })
+ @ApiQuery({ name: 'propertyId', required: true, schema: { type: 'string', format: 'uuid' } })
+ @ApiResponse({ status: 303, description: 'Redirect to the previously validated embedding page' })
+ @ApiResponse({ status: 404, description: 'Unknown, expired, invalid or out-of-scope return' })
+ async resolve(
+ @Param('reference') reference: string,
+ @Query('propertyId', new ParseUUIDPipe()) propertyId: string,
+ ) {
+ return { url: await this.service.resolvePaymentReturn(propertyId, reference) };
+ }
+}
diff --git a/apps/api/src/modules/booking-engine/booking-return.service.spec.ts b/apps/api/src/modules/booking-engine/booking-return.service.spec.ts
new file mode 100644
index 00000000..4e83c9aa
--- /dev/null
+++ b/apps/api/src/modules/booking-engine/booking-return.service.spec.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it, vi } from 'vitest';
+import { createHash } from 'node:crypto';
+import { BookingReturnService } from './booking-return.service';
+
+function setup(status = 'pending') {
+ const where = vi.fn().mockResolvedValue([{ status, createdAt: new Date() }]);
+ const db = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where }) }) };
+ const settings: Record = { BOOKING_RETURN_ORIGINS: 'https://hotel.example', PUBLIC_API_BASE_URL: 'https://api.example', NODE_ENV: 'production' };
+ const config = { get: vi.fn((key: string) => settings[key]) };
+ return { service: new BookingReturnService(db as any, config as any), where, db, settings };
+}
+
+const PROPERTY = '11111111-1111-4111-a111-111111111111';
+const referenceOf = (url: string) => new URL(url).pathname.split('/').at(-1)!;
+
+describe('BookingReturnService', () => {
+ it('preserves the embedding page and binds both outcomes to one opaque reference', () => {
+ const { service } = setup();
+ const result = service.prepare(PROPERTY, 'https://hotel.example/stays/book?lang=es&redsys=ok&haip_payment_return=old&Ds_Signature=stale&DS_MERCHANTPARAMETERS=stale#rooms');
+ const url = new URL(result.destination);
+ expect(url.pathname).toBe('/stays/book');
+ expect(url.searchParams.get('lang')).toBe('es');
+ expect(url.hash).toBe('#rooms');
+ expect(url.searchParams.has('redsys')).toBe(false);
+ expect(url.searchParams.has('Ds_Signature')).toBe(false);
+ expect(url.searchParams.has('DS_MERCHANTPARAMETERS')).toBe(false);
+ const reference = referenceOf(result.url);
+ expect(reference).toMatch(/^[A-Za-z0-9_-]{43}$/);
+ expect(new URL(result.url).origin).toBe('https://api.example');
+ expect(new URL(result.url).searchParams.get('propertyId')).toBe(PROPERTY);
+ expect(result.url.length).toBeLessThanOrEqual(250);
+ expect(result.destination).not.toContain(reference);
+ expect(result.referenceHash).toBe(createHash('sha256').update(reference).digest('hex'));
+ expect(service.prepare(PROPERTY, 'https://hotel.example/stays/book').referenceHash).not.toBe(result.referenceHash);
+ });
+
+ it.each(['https://attacker.example/book', 'https://hotel.example.attacker.example/book', 'javascript:alert(1)', 'http://hotel.example/book', 'https://user:pass@hotel.example/book', undefined])('rejects unsafe/unconfigured destination %s', (url) => {
+ expect(() => setup().service.prepare(PROPERTY, url)).toThrow(/return/i);
+ });
+
+ it.each([['pending', 'processing'], ['authorized', 'succeeded'], ['captured', 'succeeded'], ['failed', 'failed'], ['voided', 'cancelled']])('maps authoritative %s to %s without exposing guest data', async (status, expected) => {
+ const { service, where } = setup(status);
+ const reference = referenceOf(service.prepare(PROPERTY, 'https://hotel.example/book').url);
+ expect(await service.status('property-1', reference)).toEqual({ status: expected });
+ // Verify the actual Drizzle predicates, including tenant and credential hash.
+ const { PgDialect } = await import('drizzle-orm/pg-core');
+ const query = new PgDialect().sqlToQuery(where.mock.calls[0][0]);
+ expect(query.params).toContain('property-1');
+ expect(query.params).toContain(createHash('sha256').update(reference).digest('hex'));
+ expect(query.params).not.toContain(reference);
+ });
+
+ it('fails closed for malformed, unknown and expired references', async () => {
+ const { service, where, db } = setup();
+ await expect(service.status('property-1', 'redsys=ok')).rejects.toThrow(/not found/i);
+ expect(db.select).not.toHaveBeenCalled();
+ where.mockResolvedValueOnce([]);
+ await expect(service.status('property-1', 'a'.repeat(43))).rejects.toThrow(/not found/i);
+ where.mockResolvedValueOnce([{ status: 'authorized', createdAt: new Date(0) }]);
+ await expect(service.status('property-1', 'a'.repeat(43))).rejects.toThrow(/not found/i);
+ });
+
+ it.each([249, 250, 251, 2048])('preserves a %i-character host URL behind the same compact relay', async (length) => {
+ const { service, where } = setup();
+ const prefix = 'https://hotel.example/stays/book?lang=es&context=';
+ const destination = prefix + 'a'.repeat(length - prefix.length - '#rooms'.length) + '#rooms';
+ expect(destination.length).toBe(length);
+ const prepared = service.prepare(PROPERTY, destination);
+ expect(prepared.url.length).toBeLessThanOrEqual(250);
+ expect(prepared.destination).toBe(destination);
+ expect(prepared.url).not.toContain('context=');
+ const reference = referenceOf(prepared.url);
+ where.mockResolvedValue([{ bookingReturnDestination: prepared.destination, createdAt: new Date() }]);
+ const target = new URL(destination);
+ target.searchParams.set('haip_payment_return', reference);
+ expect(await service.resolve(PROPERTY, reference)).toBe(target.href);
+ });
+
+ it('accepts exactly 250 provider URL characters and rejects 251 before writing', () => {
+ const { service, settings, db } = setup();
+ const initial = service.prepare(PROPERTY, 'https://hotel.example/book');
+ settings.PUBLIC_API_BASE_URL += '/' + 'a'.repeat(250 - initial.url.length - 1);
+ expect(service.prepare(PROPERTY, 'https://hotel.example/book').url.length).toBe(250);
+ settings.PUBLIC_API_BASE_URL += 'a';
+ expect(() => service.prepare(PROPERTY, 'https://hotel.example/book')).toThrow(/250/);
+ expect(db.select).not.toHaveBeenCalled();
+ });
+
+ it.each(['http://api.example', 'https://user:password@api.example', 'https://api.example?next=evil', 'https://api.example#fragment', 'https://api.example?', 'https://api.example#', 'javascript:alert(1)'])('rejects an unsafe relay configuration %s', (base) => {
+ const { service, settings } = setup();
+ settings.PUBLIC_API_BASE_URL = base;
+ expect(() => service.prepare(PROPERTY, 'https://hotel.example/book')).toThrow(/return/i);
+ });
+
+ it('rejects malformed, unknown, expired, unbound and no-longer-allowed relay references', async () => {
+ const { service, where, db, settings } = setup();
+ await expect(service.resolve(PROPERTY, '../evil')).rejects.toThrow(/not found/i);
+ expect(db.select).not.toHaveBeenCalled();
+ where.mockResolvedValueOnce([]);
+ await expect(service.resolve(PROPERTY, 'a'.repeat(43))).rejects.toThrow(/not found/i);
+ for (const row of [
+ { bookingReturnDestination: 'https://hotel.example/book', createdAt: new Date(0) },
+ { bookingReturnDestination: null, createdAt: new Date() },
+ { bookingReturnDestination: 'https://attacker.example/book', createdAt: new Date() },
+ { bookingReturnDestination: 'https://user:pass@hotel.example/book', createdAt: new Date() },
+ ]) {
+ where.mockResolvedValueOnce([row]);
+ await expect(service.resolve(PROPERTY, 'a'.repeat(43))).rejects.toThrow(/not found/i);
+ }
+ settings.BOOKING_RETURN_ORIGINS = '';
+ where.mockResolvedValueOnce([{ bookingReturnDestination: 'https://hotel.example/book', createdAt: new Date() }]);
+ await expect(service.resolve(PROPERTY, 'a'.repeat(43))).rejects.toThrow(/not found/i);
+ });
+
+ it('binds the relay lookup to property, hash and provider without accepting a destination', async () => {
+ const { service, where } = setup();
+ where.mockResolvedValue([{ bookingReturnDestination: 'https://hotel.example/book', createdAt: new Date() }]);
+ await service.resolve(PROPERTY, 'a'.repeat(43));
+ const { PgDialect } = await import('drizzle-orm/pg-core');
+ const query = new PgDialect().sqlToQuery(where.mock.calls[0][0]);
+ expect(query.params).toEqual(expect.arrayContaining([PROPERTY, createHash('sha256').update('a'.repeat(43)).digest('hex'), 'redsys']));
+ expect(query.params).not.toContain('a'.repeat(43));
+ });
+});
diff --git a/apps/api/src/modules/booking-engine/booking-return.service.ts b/apps/api/src/modules/booking-engine/booking-return.service.ts
new file mode 100644
index 00000000..8e279382
--- /dev/null
+++ b/apps/api/src/modules/booking-engine/booking-return.service.ts
@@ -0,0 +1,105 @@
+import { BadRequestException, NotFoundException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { createHash, randomBytes } from 'node:crypto';
+import { and, eq } from 'drizzle-orm';
+import { payments } from '@telivityhaip/database';
+import { publicApiBaseUrl } from '../payment/redsys-credentials.service';
+
+const REFERENCE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
+const REFERENCE_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
+const hashReference = (reference: string) => createHash('sha256').update(reference).digest('hex');
+
+/** Limited, expiring capability: reveals payment state only, never booking credentials. */
+export class BookingReturnService {
+ constructor(private readonly db: any, private readonly config: ConfigService) {}
+
+ prepare(propertyId: string, destination?: string): { url: string; referenceHash: string; destination: string } {
+ const target = this.validateDestination(destination);
+ // The PSP limits both browser URLs to 250 characters. A server-owned relay
+ // keeps the entire hotel URL out of that field and preserves it without truncation.
+ let relay: URL;
+ try {
+ const base = new URL(publicApiBaseUrl(this.config));
+ if (!this.allowedProtocol(base) || base.username || base.password || /[?#]/.test(base.href)) {
+ throw new Error('Unsafe public API base URL');
+ }
+ const reference = randomBytes(32).toString('base64url');
+ relay = new URL(`${base.href.replace(/\/$/, '')}/api/v1/booking-return/${reference}`);
+ relay.searchParams.set('propertyId', propertyId);
+ if (relay.href.length > 250) {
+ throw new BadRequestException('Booking return relay URL exceeds the 250-character provider limit');
+ }
+ return { url: relay.href, referenceHash: hashReference(reference), destination: target.href };
+ } catch (error) {
+ if (error instanceof BadRequestException) throw error;
+ throw new BadRequestException('A valid public API base URL is required for booking returns');
+ }
+ }
+
+ private allowedProtocol(url: URL): boolean {
+ return url.protocol === 'https:' || (
+ this.config.get('NODE_ENV') !== 'production' && url.protocol === 'http:'
+ && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
+ );
+ }
+
+ private validateDestination(destination?: string): URL {
+ let url: URL;
+ try {
+ url = new URL(destination ?? '');
+ } catch {
+ throw new BadRequestException('A valid booking return URL is required');
+ }
+ const origins = (this.config.get('BOOKING_RETURN_ORIGINS') ?? '')
+ .split(',').map((origin) => origin.trim()).filter(Boolean);
+ if (!this.allowedProtocol(url) || url.username || url.password
+ || !origins.includes(url.origin)) {
+ throw new BadRequestException('Booking return URL origin is not allowed');
+ }
+ // Reload the actual host document, retaining its path, query and fragment.
+ // Neither provider outcome is proof: both return to the same status view.
+ url.searchParams.delete('redsys');
+ url.searchParams.delete('haip_payment_return');
+ for (const key of [...url.searchParams.keys()]) {
+ if (/^ds_/i.test(key)) url.searchParams.delete(key);
+ }
+ return url;
+ }
+
+ private async findPayment(propertyId: string, reference: string) {
+ if (!REFERENCE_PATTERN.test(reference)) throw new NotFoundException('Payment return not found');
+ const [payment] = await this.db.select({ status: payments.status, createdAt: payments.createdAt,
+ bookingReturnDestination: payments.bookingReturnDestination })
+ .from(payments)
+ .where(and(eq(payments.propertyId, propertyId),
+ eq(payments.bookingReturnReferenceHash, hashReference(reference)),
+ eq(payments.gatewayProvider, 'redsys')));
+ if (!payment || Date.now() - new Date(payment.createdAt).getTime() > REFERENCE_LIFETIME_MS) {
+ throw new NotFoundException('Payment return not found');
+ }
+ return payment;
+ }
+
+ async resolve(propertyId: string, reference: string): Promise {
+ // Possession is paired with explicit tenant scope. No redirect destination
+ // or payment result is taken from the request, including query parameters.
+ const payment = await this.findPayment(propertyId, reference);
+ let target: URL;
+ try {
+ target = this.validateDestination(payment.bookingReturnDestination);
+ } catch {
+ throw new NotFoundException('Payment return not found');
+ }
+ target.searchParams.set('haip_payment_return', reference);
+ return target.href;
+ }
+
+ async status(propertyId: string, reference: string) {
+ const payment = await this.findPayment(propertyId, reference);
+ const status = ['authorized', 'captured', 'settled'].includes(payment.status)
+ ? 'succeeded' : payment.status === 'failed' ? 'failed'
+ : payment.status === 'voided' ? 'cancelled'
+ : payment.status === 'pending' ? 'processing' : 'unavailable';
+ return { status };
+ }
+}
diff --git a/apps/api/src/modules/booking-engine/dto/be-create-booking.dto.ts b/apps/api/src/modules/booking-engine/dto/be-create-booking.dto.ts
index 3be97b9f..04c1df93 100644
--- a/apps/api/src/modules/booking-engine/dto/be-create-booking.dto.ts
+++ b/apps/api/src/modules/booking-engine/dto/be-create-booking.dto.ts
@@ -97,21 +97,12 @@ export class BeCreateBookingDto {
@ApiPropertyOptional({
description:
- 'Browser return URL after successful Redsys hosted checkout (required when PAYMENT_GATEWAY=redsys)',
+ 'Exact embedding page URL for hosted checkout; origin must be configured in BOOKING_RETURN_ORIGINS',
})
@IsOptional()
@IsString()
@MaxLength(2048)
- redirectUrlOk?: string;
-
- @ApiPropertyOptional({
- description:
- 'Browser return URL after failed/cancelled Redsys hosted checkout (required when PAYMENT_GATEWAY=redsys)',
- })
- @IsOptional()
- @IsString()
- @MaxLength(2048)
- redirectUrlKo?: string;
+ returnUrl?: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
diff --git a/apps/api/src/modules/integrations/integrations.service.ts b/apps/api/src/modules/integrations/integrations.service.ts
index 131504a8..bfbadc11 100644
--- a/apps/api/src/modules/integrations/integrations.service.ts
+++ b/apps/api/src/modules/integrations/integrations.service.ts
@@ -1,4 +1,4 @@
-import { Inject, Injectable, NotFoundException } from '@nestjs/common';
+import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { and, asc, eq } from 'drizzle-orm';
import {
auditLogs,
@@ -8,6 +8,7 @@ import {
import { actorFields, type AuditActor } from '../../common/audit/audit-actor';
import { DRIZZLE } from '../../database/database.module';
import { ListIntegrationsDto, UpsertPropertyIntegrationDto } from './dto/integration-registry.dto';
+import { encryptCredentialPlaintext, loadMigrationCredentialKeyRingFromEnv } from '../../common/crypto/credential-encryption';
export function maskSecret(secret: string): string {
@@ -22,30 +23,37 @@ export function sanitizeIntegrationConfig(
): Record {
const raw = { ...(config ?? {}) };
if (slug !== 'redsys') return raw;
- const secret = raw['secretKey'];
- delete raw['secretKey'];
- if (typeof secret === 'string' && secret.trim()) {
- raw['secretKeyMasked'] = maskSecret(secret);
- }
+ const configured = Boolean(raw['secretKeyEncrypted']) || Boolean(redsysSecret(raw));
+ for (const key of REDSYS_SECRET_FIELDS) delete raw[key];
+ delete raw['secretKeyEncrypted'];
+ delete raw['secretKeyMasked'];
+ if (configured) raw['secretKeyMasked'] = '••••••••';
return raw;
}
+const REDSYS_SECRET_FIELDS = ['secretKey', 'secret_key', 'clave'] as const;
+
+function redsysSecret(config: Record): string | undefined {
+ for (const field of REDSYS_SECRET_FIELDS) {
+ const value = config[field];
+ if (typeof value === 'string' && value.trim()) return value.trim();
+ }
+ return undefined;
+}
+
export function mergeRedsysConfig(
incoming: Record,
existing: Record | null | undefined,
): Record {
- const merged = { ...incoming };
- const nextSecret = merged['secretKey'];
- if (typeof nextSecret !== 'string' || !nextSecret.trim()) {
- const prev = existing?.['secretKey'];
- if (typeof prev === 'string' && prev.trim()) {
- merged['secretKey'] = prev;
- } else {
- delete merged['secretKey'];
- }
+ if (Object.hasOwn(incoming, 'secretKeyEncrypted')) {
+ throw new BadRequestException('Credential ciphertext cannot be supplied through integration config');
}
+ const merged = { ...existing, ...incoming };
+ const secret = redsysSecret(incoming) ?? (!existing?.['secretKeyEncrypted'] ? redsysSecret(existing ?? {}) : undefined);
+ for (const key of REDSYS_SECRET_FIELDS) delete merged[key];
// Never persist UI-only masked values.
delete merged['secretKeyMasked'];
+ if (secret) merged['secretKeyEncrypted'] = encryptCredentialPlaintext(secret, loadMigrationCredentialKeyRingFromEnv());
return merged;
}
@@ -53,6 +61,24 @@ export function mergeRedsysConfig(
export class IntegrationsService {
constructor(@Inject(DRIZZLE) private readonly db: any) {}
+ /** Tenant-scoped, idempotent data migration; ciphertext uses the existing key ring. */
+ async protectRedsysCredentials(propertyId: string): Promise {
+ await this.db.transaction(async (tx: any) => {
+ const [row] = await tx.select().from(propertyIntegrations)
+ .where(and(eq(propertyIntegrations.propertyId, propertyId), eq(propertyIntegrations.catalogSlug, 'redsys')))
+ .limit(1).for('update');
+ if (!row || !REDSYS_SECRET_FIELDS.some((key) => Object.hasOwn(row.config, key))) return;
+ const config = mergeRedsysConfig({}, row.config);
+ await tx.update(propertyIntegrations).set({ config, updatedAt: new Date() })
+ .where(and(eq(propertyIntegrations.id, row.id), eq(propertyIntegrations.propertyId, propertyId)));
+ await tx.insert(auditLogs).values({
+ propertyId, entityType: 'property_integration', entityId: row.id,
+ action: 'property_integration.credentials_protected',
+ newValue: { catalogSlug: 'redsys', protected: Boolean(config['secretKeyEncrypted']) },
+ });
+ });
+ }
+
async listCatalog(filters: ListIntegrationsDto = {}) {
const conditions: any[] = [];
if (filters.category) {
diff --git a/apps/api/src/modules/payment/gateways/redsys-crypto.ts b/apps/api/src/modules/payment/gateways/redsys-crypto.ts
index 2f707c87..9b9f2229 100644
--- a/apps/api/src/modules/payment/gateways/redsys-crypto.ts
+++ b/apps/api/src/modules/payment/gateways/redsys-crypto.ts
@@ -1,4 +1,6 @@
import { createCipheriv, createHmac, timingSafeEqual } from 'crypto';
+import { Decimal } from 'decimal.js';
+import { assertLedgerCurrencySupported } from '@telivityhaip/booking-requests';
export const REDSYS_SIGNATURE_VERSION = 'HMAC_SHA512_V2';
@@ -36,8 +38,14 @@ export function redsysCurrencyCode(currency: string): string {
}
/** Amount in minor units as a decimal-less string (e.g. 12.34 EUR → "1234"). */
-export function redsysAmountString(amountMajor: number): string {
- return String(Math.round(amountMajor * 100));
+export function redsysAmountString(amountMajor: number | string, currency = 'EUR'): string {
+ redsysCurrencyCode(currency);
+ const exponent = assertLedgerCurrencySupported(currency);
+ const minor = new Decimal(amountMajor).times(new Decimal(10).pow(exponent));
+ if (!minor.isFinite() || minor.lte(0) || !minor.isInteger()) {
+ throw new Error(`Amount must be positive and use ${currency} minor units`);
+ }
+ return minor.toFixed(0);
}
/** Redsys order numbers: 4–12 chars, first 4 numeric. */
diff --git a/apps/api/src/modules/payment/gateways/redsys-gateway.spec.ts b/apps/api/src/modules/payment/gateways/redsys-gateway.spec.ts
index aa85c44d..8f7e5914 100644
--- a/apps/api/src/modules/payment/gateways/redsys-gateway.spec.ts
+++ b/apps/api/src/modules/payment/gateways/redsys-gateway.spec.ts
@@ -2,6 +2,7 @@ import { ConfigService } from '@nestjs/config';
import { RedsysGateway } from './redsys-gateway';
import {
REDSYS_SANDBOX,
+ REDSYS_SIGNATURE_VERSION,
decodeMerchantParameters,
diversifyKey,
encodeMerchantParameters,
@@ -30,6 +31,18 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}
+function lifecycleResponse(type = '2', changes: Record = {}, envelope: Record = {}) {
+ const params = encodeMerchantParameters({
+ Ds_Order: '1234ABCDEF', Ds_MerchantCode: REDSYS_SANDBOX.merchantCode, Ds_Terminal: '1',
+ Ds_Amount: '1234', Ds_Currency: '978', Ds_TransactionType: type,
+ Ds_Response: type === '9' ? '0400' : '0900', ...changes,
+ } as Record);
+ return jsonResponse({
+ Ds_MerchantParameters: params, Ds_SignatureVersion: REDSYS_SIGNATURE_VERSION,
+ Ds_Signature: signMerchantParameters(params, REDSYS_SANDBOX.secretKey, '1234ABCDEF'), ...envelope,
+ });
+}
+
describe('redsys-crypto', () => {
it('matches Redsys HMAC_SHA512_V2 golden vector', () => {
const secret = 'sq7HjrUOBfKmC576ILgskD5srU870gJ7';
@@ -64,13 +77,18 @@ describe('RedsysGateway', () => {
fetchMock.mockReset();
});
- it('runs in console mode without credentials', async () => {
+ it.each(['production', 'staging', 'development'])('fails closed without credentials in %s', async (environment) => {
const gw = new RedsysGateway(
- mockConfig({ REDSYS_MERCHANT_CODE: '', REDSYS_SECRET_KEY: '' }),
+ mockConfig({ NODE_ENV: environment, PAYMENT_GATEWAY: 'redsys', REDSYS_MERCHANT_CODE: '', REDSYS_SECRET_KEY: '' }),
{ fetchFn: fetchMock },
);
- const result = await gw.authorize('tok', 10, 'EUR');
- expect(result.success).toBe(true);
+ for (const result of await Promise.all([
+ gw.authorize('tok', 10, 'EUR'), gw.capture('1234ABC', 10),
+ gw.void('1234ABC'), gw.refund('1234ABC', 10),
+ ])) {
+ expect(result.success).toBe(false);
+ expect(result.errorMessage).toMatch(/credentials/i);
+ }
expect(fetchMock).not.toHaveBeenCalled();
});
@@ -109,11 +127,29 @@ describe('RedsysGateway', () => {
expect(result.errorMessage).toMatch(/redirect/i);
});
+ it.each([['EUR', 12.34, '1234'], ['JPY', 100, '100']] as const)(
+ 'sends exact %s minor units for authorization, capture and refund', async (currency, amount, expected) => {
+ const gw = new RedsysGateway(mockConfig(), { fetchFn: fetchMock });
+ const result = await gw.authorize('redsys_redirect', amount, currency, {
+ redirect: { merchantUrl: 'https://api.example/notify', urlOk: 'https://hotel.example/ok', urlKo: 'https://hotel.example/ko' },
+ });
+ expect(decodeMerchantParameters(result.nextAction!.formFields.Ds_MerchantParameters).DS_MERCHANT_AMOUNT).toBe(expected);
+ fetchMock.mockResolvedValue(jsonResponse({ errorCode: 'TEST_DECLINE' }));
+ await gw.capture('1234ABCDEF', amount, { currencyCode: currency });
+ await gw.refund('1234ABCDEF', amount, { currencyCode: currency });
+ for (const [, request] of fetchMock.mock.calls) {
+ expect(decodeMerchantParameters(JSON.parse(request.body).Ds_MerchantParameters).DS_MERCHANT_AMOUNT).toBe(expected);
+ }
+ },
+ );
+
it('captures via REST using per-call merchant credentials', async () => {
const orderId = '1234ABCDEF';
const responseParams = encodeMerchantParameters({
Ds_Order: orderId,
Ds_Response: '0900',
+ Ds_Amount: '1234', Ds_Currency: '978', Ds_TransactionType: '2',
+ Ds_MerchantCode: REDSYS_SANDBOX.merchantCode, Ds_Terminal: '1',
});
const signature = signMerchantParameters(
responseParams,
@@ -148,4 +184,56 @@ describe('RedsysGateway', () => {
expect(sent.DS_MERCHANT_TRANSACTIONTYPE).toBe('2');
expect(sent.DS_MERCHANT_AMOUNT).toBe('1234');
});
+
+ it.each([
+ ['missing signature', {}, { Ds_Signature: undefined }],
+ ['missing signature version', {}, { Ds_SignatureVersion: undefined }],
+ ['wrong signature version', {}, { Ds_SignatureVersion: 'HMAC_SHA256_V1' }],
+ ['wrong signature', {}, { Ds_Signature: 'invalid' }],
+ ['wrong order', { Ds_Order: '9876OTHER' }, {}],
+ ['wrong merchant', { Ds_MerchantCode: '111111111' }, {}],
+ ['wrong terminal', { Ds_Terminal: '2' }, {}],
+ ['malformed terminal', { Ds_Terminal: '1x' }, {}],
+ ['wrong amount', { Ds_Amount: '1235' }, {}],
+ ['missing amount', { Ds_Amount: undefined }, {}],
+ ['wrong currency', { Ds_Currency: '392' }, {}],
+ ['wrong operation', { Ds_TransactionType: '9', Ds_Response: '0400' }, {}],
+ ['wrong success code', { Ds_Response: '0000' }, {}],
+ ['void success code', { Ds_Response: '0400' }, {}],
+ ['malformed success code', { Ds_Response: '0900junk' }, {}],
+ ['numeric success code', { Ds_Response: 900 }, {}],
+ ['missing success code', { Ds_Response: undefined }, {}],
+ ['malformed parameters', {}, { Ds_MerchantParameters: 'not-json' }],
+ ['non-string parameters', {}, { Ds_MerchantParameters: { unexpected: true } }],
+ ])('rejects a lifecycle response with %s', async (_name, changes, envelope) => {
+ fetchMock.mockResolvedValue(lifecycleResponse('2', changes as any, envelope as any));
+ const gateway = new RedsysGateway(mockConfig(), { fetchFn: fetchMock });
+ await expect(gateway.capture('1234ABCDEF', 12.34, { currencyCode: 'EUR' })).resolves.toMatchObject({ success: false });
+ });
+
+ it.each([['capture', '2'], ['refund', '3'], ['void', '9']] as const)(
+ 'accepts an authenticated matching %s response', async (operation, type) => {
+ fetchMock.mockResolvedValue(lifecycleResponse(type));
+ const gateway = new RedsysGateway(mockConfig(), { fetchFn: fetchMock });
+ const options = { currencyCode: 'EUR', authorizedAmount: 12.34 };
+ const result = operation === 'void' ? await gateway.void('1234ABCDEF', options)
+ : await gateway[operation]('1234ABCDEF', 12.34, options);
+ expect(result.success).toBe(true);
+ },
+ );
+
+ it.each(['capture', 'refund', 'void'] as const)('rejects %s without its amount before contacting the provider', async (operation) => {
+ fetchMock.mockResolvedValue(jsonResponse({ errorCode: 'TEST_DECLINE' }));
+ const gateway = new RedsysGateway(mockConfig(), { fetchFn: fetchMock });
+ await expect(gateway[operation]('1234ABCDEF')).resolves.toMatchObject({ success: false });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('returns a safe failure for correctly signed malformed response JSON', async () => {
+ const parameters = Buffer.from('null').toString('base64url');
+ fetchMock.mockResolvedValue(jsonResponse({ Ds_MerchantParameters: parameters, Ds_SignatureVersion: REDSYS_SIGNATURE_VERSION,
+ Ds_Signature: signMerchantParameters(parameters, REDSYS_SANDBOX.secretKey, '1234ABCDEF') }));
+ const gateway = new RedsysGateway(mockConfig(), { fetchFn: fetchMock });
+ await expect(gateway.capture('1234ABCDEF', 12.34)).resolves.toMatchObject({ success: false });
+ });
});
diff --git a/apps/api/src/modules/payment/gateways/redsys-gateway.ts b/apps/api/src/modules/payment/gateways/redsys-gateway.ts
index c03969af..9694df22 100644
--- a/apps/api/src/modules/payment/gateways/redsys-gateway.ts
+++ b/apps/api/src/modules/payment/gateways/redsys-gateway.ts
@@ -5,7 +5,6 @@ import type {
PaymentGatewayCallOptions,
PaymentGatewayResult,
} from '../interfaces/payment-gateway.interface';
-import { createConsolePaymentGateway } from './console-payment-gateway';
import { gatewayJsonRequest, type GatewayFetchFn } from './payment-gateway-http';
import {
REDSYS_REDIRECT_URLS,
@@ -15,7 +14,6 @@ import {
decodeMerchantParameters,
encodeMerchantParameters,
generateRedsysOrderId,
- isRedsysSuccessResponse,
redsysAmountString,
redsysCurrencyCode,
signMerchantParameters,
@@ -47,21 +45,19 @@ interface RedsysRestResponse {
*
* Credentials: `options.merchantCredentials` (per-property Integrations config),
* else env `REDSYS_MERCHANT_CODE` / `REDSYS_TERMINAL` / `REDSYS_SECRET_KEY` /
- * `REDSYS_ENV`. Missing credentials → console (mock) mode.
+ * `REDSYS_ENV`. Missing credentials fail closed; demos must select the mock gateway.
*/
@Injectable()
export class RedsysGateway implements PaymentGateway {
private readonly logger = new Logger(RedsysGateway.name);
private readonly envCredentials: RedsysMerchantCredentials | null;
private readonly fetchFn: GatewayFetchFn;
- private readonly consoleDelegate: PaymentGateway;
constructor(
configService: ConfigService,
deps?: { fetchFn?: GatewayFetchFn },
) {
this.fetchFn = deps?.fetchFn ?? fetch;
- this.consoleDelegate = createConsolePaymentGateway('Redsys');
const merchantCode = configService.get('REDSYS_MERCHANT_CODE')?.trim();
const terminal = configService.get('REDSYS_TERMINAL')?.trim() || '001';
@@ -111,7 +107,7 @@ export class RedsysGateway implements PaymentGateway {
): Promise {
const creds = this.resolveCredentials(options);
if (!creds) {
- return this.consoleDelegate.authorize(token, amount, currency, options);
+ return Promise.resolve({ success: false, transactionId: '', errorMessage: 'Redsys credentials are not configured' });
}
return Promise.resolve(
this.buildRedirectAuthorize(amount, currency, creds, options),
@@ -125,7 +121,7 @@ export class RedsysGateway implements PaymentGateway {
): Promise {
const creds = this.resolveCredentials(options);
if (!creds) {
- return this.consoleDelegate.capture(transactionId, amount, options);
+ return Promise.resolve({ success: false, transactionId, errorMessage: 'Redsys credentials are not configured' });
}
return this.restOperation({
creds,
@@ -143,12 +139,13 @@ export class RedsysGateway implements PaymentGateway {
): Promise {
const creds = this.resolveCredentials(options);
if (!creds) {
- return this.consoleDelegate.void(transactionId, options);
+ return Promise.resolve({ success: false, transactionId, errorMessage: 'Redsys credentials are not configured' });
}
return this.restOperation({
creds,
orderId: transactionId,
transactionType: '9',
+ amount: options?.authorizedAmount,
currency: options?.currencyCode ?? 'EUR',
options,
});
@@ -161,7 +158,7 @@ export class RedsysGateway implements PaymentGateway {
): Promise {
const creds = this.resolveCredentials(options);
if (!creds) {
- return this.consoleDelegate.refund(transactionId, amount, options);
+ return Promise.resolve({ success: false, transactionId, errorMessage: 'Redsys credentials are not configured' });
}
return this.restOperation({
creds,
@@ -208,9 +205,9 @@ export class RedsysGateway implements PaymentGateway {
};
}
- const orderId = options.redirect.orderId?.trim() || generateRedsysOrderId();
+ const orderId = generateRedsysOrderId();
const params: Record = {
- DS_MERCHANT_AMOUNT: redsysAmountString(amount),
+ DS_MERCHANT_AMOUNT: redsysAmountString(amount, currency),
DS_MERCHANT_ORDER: orderId,
DS_MERCHANT_MERCHANTCODE: creds.merchantCode,
DS_MERCHANT_CURRENCY: redsysCurrencyCode(currency),
@@ -256,16 +253,21 @@ export class RedsysGateway implements PaymentGateway {
options?: PaymentGatewayCallOptions;
}): Promise {
const { creds, orderId, transactionType, amount, currency, options } = input;
+ let amountMinor: string;
+ try {
+ if (amount === undefined) throw new Error();
+ amountMinor = redsysAmountString(amount, currency ?? 'EUR');
+ } catch {
+ return { success: false, transactionId: orderId, errorMessage: 'Redsys requires an amount in supported currency minor units' };
+ }
const params: Record = {
DS_MERCHANT_ORDER: orderId,
DS_MERCHANT_MERCHANTCODE: creds.merchantCode,
DS_MERCHANT_TERMINAL: creds.terminal,
DS_MERCHANT_TRANSACTIONTYPE: transactionType,
DS_MERCHANT_CURRENCY: redsysCurrencyCode(currency ?? 'EUR'),
+ DS_MERCHANT_AMOUNT: amountMinor,
};
- if (amount !== undefined) {
- params['DS_MERCHANT_AMOUNT'] = redsysAmountString(amount);
- }
const merchantParameters = encodeMerchantParameters(params);
const signature = signMerchantParameters(
@@ -292,7 +294,7 @@ export class RedsysGateway implements PaymentGateway {
this.fetchFn,
);
- if (!res.ok || !res.data?.Ds_MerchantParameters) {
+ if (!res.ok || typeof res.data?.Ds_MerchantParameters !== 'string' || !res.data.Ds_MerchantParameters) {
return {
success: false,
transactionId: orderId,
@@ -305,7 +307,8 @@ export class RedsysGateway implements PaymentGateway {
}
if (
- res.data.Ds_Signature &&
+ res.data.Ds_SignatureVersion !== REDSYS_SIGNATURE_VERSION ||
+ typeof res.data.Ds_Signature !== 'string' ||
!verifyMerchantParametersSignature(
res.data.Ds_MerchantParameters,
res.data.Ds_Signature,
@@ -320,13 +323,31 @@ export class RedsysGateway implements PaymentGateway {
};
}
- const decoded = decodeMerchantParameters(res.data.Ds_MerchantParameters);
- const dsResponse = decoded['Ds_Response'] ?? decoded['DS_RESPONSE'];
- if (!isRedsysSuccessResponse(dsResponse)) {
+ let decoded: Record;
+ try {
+ decoded = decodeMerchantParameters(res.data.Ds_MerchantParameters);
+ if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) throw new Error();
+ } catch {
+ return { success: false, transactionId: orderId, errorMessage: 'Malformed Redsys response' };
+ }
+ const field = (name: string): string => {
+ const value = decoded[name] ?? decoded[name.toUpperCase()];
+ return typeof value === 'string' ? value : '';
+ };
+ const dsResponse = field('Ds_Response');
+ const terminal = field('Ds_Terminal');
+ const responseAmount = field('Ds_Amount');
+ if (field('Ds_Order') !== orderId
+ || field('Ds_MerchantCode') !== creds.merchantCode
+ || !/^\d+$/.test(terminal) || Number(terminal) !== Number(creds.terminal)
+ || field('Ds_Currency') !== params['DS_MERCHANT_CURRENCY']
+ || field('Ds_TransactionType') !== transactionType
+ || !/^\d+$/.test(responseAmount) || BigInt(responseAmount) !== BigInt(amountMinor)
+ || dsResponse !== (transactionType === '9' ? '0400' : '0900')) {
return {
success: false,
transactionId: orderId,
- errorMessage: `Redsys declined with Ds_Response=${dsResponse ?? 'unknown'}`,
+ errorMessage: 'Redsys response does not confirm the requested operation',
};
}
diff --git a/apps/api/src/modules/payment/payment.module.ts b/apps/api/src/modules/payment/payment.module.ts
index e18b102b..96de4e3f 100644
--- a/apps/api/src/modules/payment/payment.module.ts
+++ b/apps/api/src/modules/payment/payment.module.ts
@@ -1,11 +1,8 @@
-import { Module, forwardRef } from '@nestjs/common';
+import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { FolioModule } from '../folio/folio.module';
import { WebhookModule } from '../webhook/webhook.module';
import { IntegrationsModule } from '../integrations/integrations.module';
-import { AccountingModule } from '../accounting/accounting.module';
-import { ReservationModule } from '../reservation/reservation.module';
-import { RedsysPaymentFinalizer } from './redsys-payment-finalizer.service';
import { PaymentController } from './payment.controller';
import { StripeWebhookController } from './stripe-webhook.controller';
import { RedsysWebhookController } from './redsys-webhook.controller';
@@ -40,14 +37,7 @@ function createSavedPaymentMethodGateway(configService: ConfigService) {
* braintree, wise, redsys). When unset, STRIPE_MODE controls legacy behavior.
*/
@Module({
- imports: [
- ConfigModule,
- FolioModule,
- WebhookModule,
- IntegrationsModule,
- AccountingModule,
- forwardRef(() => ReservationModule),
- ],
+ imports: [ConfigModule, FolioModule, WebhookModule, IntegrationsModule],
controllers: [
PaymentController,
StripeWebhookController,
@@ -56,7 +46,6 @@ function createSavedPaymentMethodGateway(configService: ConfigService) {
providers: [
PaymentService,
RedsysCredentialsService,
- RedsysPaymentFinalizer,
{
provide: PAYMENT_GATEWAY,
useFactory: (configService: ConfigService) =>
@@ -70,6 +59,6 @@ function createSavedPaymentMethodGateway(configService: ConfigService) {
inject: [ConfigService],
},
],
- exports: [PaymentService, PAYMENT_GATEWAY, SAVED_PAYMENT_METHOD_GATEWAY, RedsysPaymentFinalizer],
+ 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 742d0556..c2ccbe04 100644
--- a/apps/api/src/modules/payment/payment.service.spec.ts
+++ b/apps/api/src/modules/payment/payment.service.spec.ts
@@ -7,6 +7,8 @@ import { WebhookService } from '../webhook/webhook.service';
import { DRIZZLE } from '../../database/database.module';
import { PAYMENT_GATEWAY } from './interfaces/payment-gateway.interface';
import { RedsysCredentialsService } from './redsys-credentials.service';
+import { RedsysGateway } from './gateways/redsys-gateway';
+import { REDSYS_SANDBOX, REDSYS_SIGNATURE_VERSION, decodeMerchantParameters, encodeMerchantParameters, signMerchantParameters } from './gateways/redsys-crypto';
const mockFolio = {
id: 'folio-001',
@@ -116,6 +118,25 @@ describe('PaymentService', () => {
});
describe('recordPayment', () => {
+ it.each([['JPY', '100.10'], ['EUR', '1.001'], ['KWD', '1.00']])('rejects unsupported %s precision before payment writes', async (currencyCode, amount) => {
+ mockRedsysCredentials.resolveForProperty.mockResolvedValueOnce({ merchantCode: '999008881', terminal: '001', secretKey: 'test-key', environment: 'test' } as any);
+ await expect(service.authorizePayment({
+ propertyId: 'prop-001', folioId: 'folio-001', amount, currencyCode,
+ gatewayProvider: 'redsys', gatewayPaymentToken: 'redsys_redirect',
+ redirectUrlOk: 'https://hotel.example/ok', redirectUrlKo: 'https://hotel.example/ko',
+ })).rejects.toThrow(/currency|minor units/i);
+ expect(mockDb.insert).not.toHaveBeenCalled();
+ expect(mockGateway.authorize).not.toHaveBeenCalled();
+ });
+ it('rejects unconfigured Redsys authorization before persisting a payment', async () => {
+ await expect(service.authorizePayment({
+ propertyId: 'prop-001', folioId: 'folio-001', amount: '10.00', currencyCode: 'EUR',
+ gatewayProvider: 'redsys', gatewayPaymentToken: 'redsys_redirect',
+ redirectUrlOk: 'https://hotel.example/ok', redirectUrlKo: 'https://hotel.example/ko',
+ })).rejects.toThrow(/credentials/);
+ expect(mockDb.insert).not.toHaveBeenCalled();
+ expect(mockGateway.authorize).not.toHaveBeenCalled();
+ });
it('should record cash payment with status captured and recalculate balance', async () => {
const result = await service.recordPayment({
folioId: 'folio-001',
@@ -292,6 +313,23 @@ describe('PaymentService', () => {
});
describe('authorizePayment', () => {
+ it('persists internal deposit finalization settings only for a pending authorization', async () => {
+ mockFolioService.findById.mockResolvedValue({ ...mockFolio, reservationId: 'res-001' } as any);
+ mockGateway.authorize.mockResolvedValueOnce({ success: true, transactionId: 'order-001', providerStatus: 'requires_action', nextAction: { type: 'redirect' } } as any);
+ const finalization = { deposit: { reservationId: 'res-001', isRefundable: false, autoConfirm: true } };
+ const result = await (service.authorizePayment as any)({ folioId: 'folio-001', propertyId: 'prop-001', amount: '150.00', currencyCode: 'USD', gatewayProvider: 'stripe', gatewayPaymentToken: 'token' }, finalization, { returnReferenceHash: 'a'.repeat(64), returnDestination: 'https://hotel.example/stays/book' });
+ const inserted = (mockDb.insert as any).mock.results[0].value.values.mock.calls[0][0];
+ expect(inserted).toMatchObject({ status: 'pending', authorizationFinalization: finalization, bookingReturnReferenceHash: 'a'.repeat(64), bookingReturnDestination: 'https://hotel.example/stays/book' });
+ expect(result).not.toHaveProperty('bookingReturnReferenceHash');
+ expect(result).not.toHaveProperty('bookingReturnDestination');
+ });
+
+ it('rejects an internal deposit linked to a different reservation than the folio', async () => {
+ mockFolioService.findById.mockResolvedValue({ ...mockFolio, reservationId: 'res-001' } as any);
+ await expect((service.authorizePayment as any)({ folioId: 'folio-001', propertyId: 'prop-001', amount: '150.00', currencyCode: 'USD', gatewayProvider: 'stripe', gatewayPaymentToken: 'token' }, { deposit: { reservationId: 'other', isRefundable: false, autoConfirm: true } })).rejects.toThrow(BadRequestException);
+ expect(mockGateway.authorize).not.toHaveBeenCalled();
+ });
+
it('should call gateway and create authorized payment', async () => {
const authPayment = { ...mockPayment, status: 'authorized', isPreAuthorization: true };
const db = createMockDb([authPayment]);
@@ -459,6 +497,33 @@ describe('PaymentService', () => {
});
describe('voidPayment', () => {
+ it.each(['capturePayment', 'voidPayment'] as const)('rejects %s with missing property credentials before claiming the payment', async (operation) => {
+ const db = createMockDb([{ ...mockPayment, status: 'authorized', gatewayProvider: 'redsys' }]);
+ const credentials = { resolveForProperty: vi.fn().mockResolvedValue(null) };
+ const svc = new PaymentService(db, mockFolioService as any, mockGateway, mockWebhookService as any, mockConfigService as any, credentials as any);
+ await expect(svc[operation](mockPayment.id, mockPayment.propertyId)).rejects.toThrow(/credentials/);
+ expect(db.update).not.toHaveBeenCalled();
+ });
+ it.each(['capturePayment', 'voidPayment'] as const)('leaves the payment authorized when %s credentials cannot be resolved', async (operation) => {
+ const db = createMockDb([{ ...mockPayment, status: 'authorized', gatewayProvider: 'redsys' }]);
+ const credentials = { resolveForProperty: vi.fn().mockRejectedValue(new Error('Redsys credentials are unavailable')) };
+ const svc = new PaymentService(db, mockFolioService as any, mockGateway, mockWebhookService as any, mockConfigService as any, credentials as any);
+ await expect(svc[operation](mockPayment.id, mockPayment.propertyId)).rejects.toThrow(/unavailable/);
+ expect(db.update).not.toHaveBeenCalled();
+ });
+ it('sends the persisted original JPY amount and currency in a type-9 void', async () => {
+ const order = '1234ABCDEF';
+ const row = { ...mockPayment, status: 'authorized', gatewayProvider: 'redsys', gatewayTransactionId: order, currencyCode: 'JPY', amount: '150.00' };
+ const db = createMockDb([row]);
+ const encoded = encodeMerchantParameters({ Ds_Order: order, Ds_Response: '0400', Ds_Amount: '150', Ds_Currency: '392', Ds_TransactionType: '9', Ds_MerchantCode: REDSYS_SANDBOX.merchantCode, Ds_Terminal: '1' });
+ const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({ Ds_MerchantParameters: encoded, Ds_SignatureVersion: REDSYS_SIGNATURE_VERSION, Ds_Signature: signMerchantParameters(encoded, REDSYS_SANDBOX.secretKey, order) })));
+ const creds = { resolveForProperty: vi.fn().mockResolvedValue({ ...REDSYS_SANDBOX, environment: 'test' }) };
+ const gateway = new RedsysGateway({ get: () => undefined } as any, { fetchFn: request });
+ const svc = new PaymentService(db, mockFolioService as any, gateway, mockWebhookService as any, mockConfigService as any, creds as any);
+ await svc.voidPayment(row.id, row.propertyId);
+ const parameters = decodeMerchantParameters(JSON.parse(request.mock.calls[0][1].body).Ds_MerchantParameters);
+ expect(parameters).toMatchObject({ DS_MERCHANT_AMOUNT: '150', DS_MERCHANT_CURRENCY: '392', DS_MERCHANT_TRANSACTIONTYPE: '9' });
+ });
it('should void an authorized payment', async () => {
const authorizedPayment = { ...mockPayment, status: 'authorized' };
const voidedPayment = { ...mockPayment, status: 'voided' };
diff --git a/apps/api/src/modules/payment/payment.service.ts b/apps/api/src/modules/payment/payment.service.ts
index a55d4c6b..bcc026a0 100644
--- a/apps/api/src/modules/payment/payment.service.ts
+++ b/apps/api/src/modules/payment/payment.service.ts
@@ -23,11 +23,10 @@ import { AuthorizePaymentDto } from './dto/authorize-payment.dto';
import { ListPaymentsDto } from './dto/list-payments.dto';
import { sumRefundChildren, parentCountsTowardFolioBalance } from './payment-ledger';
import { RedsysCredentialsService } from './redsys-credentials.service';
+import { redsysAmountString } from './gateways/redsys-crypto';
import {
resolvePaymentGatewayProvider,
} from './payment-gateway.factory';
-import { generateRedsysOrderId } from './gateways/redsys-crypto';
-import { withRedsysCheckoutParams } from './redsys-checkout-url';
const CARD_METHODS = ['credit_card', 'debit_card', 'vcc'];
@@ -36,6 +35,10 @@ export type RefundPaymentOptions = {
idempotencyKey?: string;
};
+export type AuthorizationFinalization = NonNullable<
+ typeof payments.$inferSelect.authorizationFinalization
+>;
+
@Injectable()
export class PaymentService {
constructor(
@@ -118,11 +121,18 @@ export class PaymentService {
return this.safePaymentResponse(payment);
}
- async authorizePayment(dto: AuthorizePaymentDto) {
+ async authorizePayment(
+ dto: AuthorizePaymentDto,
+ finalization?: AuthorizationFinalization,
+ bookingReturn?: { returnReferenceHash: string; returnDestination: string },
+ ) {
const folio = await this.folioService.findById(dto.folioId, dto.propertyId);
if (folio.status !== 'open') {
throw new BadRequestException('Cannot authorize payment on a folio that is not open');
}
+ if (finalization && folio.reservationId !== finalization.deposit.reservationId) {
+ throw new BadRequestException('Deposit reservation must match the payment folio');
+ }
const gatewayOptions = await this.buildAuthorizeGatewayOptions(dto);
@@ -179,6 +189,9 @@ export class PaymentService {
amount: dto.amount,
currencyCode: dto.currencyCode,
status: requiresAction ? 'pending' : 'authorized',
+ authorizationFinalization: requiresAction ? finalization ?? null : null,
+ bookingReturnReferenceHash: bookingReturn?.returnReferenceHash ?? null,
+ bookingReturnDestination: bookingReturn?.returnDestination ?? null,
isPreAuthorization: true,
preAuthExpiresAt: preAuthExpiry,
gatewayProvider: dto.gatewayProvider,
@@ -203,16 +216,23 @@ export class PaymentService {
return {
...this.safePaymentResponse(payment),
- ...(requiresAction
- ? {
- nextAction: result.nextAction as PaymentGatewayNextAction,
- // Opaque Redsys order id — used as the hosted-checkout return token.
- gatewayTransactionId: payment.gatewayTransactionId,
- }
- : {}),
+ ...(requiresAction ? { nextAction: result.nextAction as PaymentGatewayNextAction } : {}),
};
}
+ /** Preflight also used by checkout before guest/reservation/folio writes. */
+ async assertAuthorizationAvailable(propertyId: string, provider: string, amount: string, currency: string) {
+ if (provider.toLowerCase() !== 'redsys') return;
+ const credentials = await this.redsysCredentials.resolveForProperty(propertyId);
+ if (!credentials) throw new BadRequestException('Redsys credentials are not configured');
+ try {
+ redsysAmountString(amount, currency);
+ } catch {
+ throw new BadRequestException('Amount or currency has unsupported Redsys minor units');
+ }
+ return credentials;
+ }
+
private async buildAuthorizeGatewayOptions(
dto: AuthorizePaymentDto,
): Promise {
@@ -227,16 +247,14 @@ export class PaymentService {
);
}
- const creds = await this.redsysCredentials.resolveForProperty(dto.propertyId);
- const orderId = generateRedsysOrderId();
+ const creds = await this.assertAuthorizationAvailable(dto.propertyId, provider, dto.amount, dto.currencyCode);
const options: PaymentGatewayCallOptions = {
propertyId: dto.propertyId,
currencyCode: dto.currencyCode,
redirect: {
merchantUrl: this.redsysCredentials.merchantNotificationUrl(),
- urlOk: withRedsysCheckoutParams(dto.redirectUrlOk, orderId, 'ok'),
- urlKo: withRedsysCheckoutParams(dto.redirectUrlKo, orderId, 'ko'),
- orderId,
+ urlOk: dto.redirectUrlOk,
+ urlKo: dto.redirectUrlKo,
},
};
if (creds) {
@@ -256,7 +274,7 @@ export class PaymentService {
if (payment.gatewayProvider?.toLowerCase() !== 'redsys') {
return undefined;
}
- const creds = await this.redsysCredentials.resolveForProperty(payment.propertyId);
+ const creds = await this.assertAuthorizationAvailable(payment.propertyId, 'redsys', payment.amount, payment.currencyCode);
const options: PaymentGatewayCallOptions = {
propertyId: payment.propertyId,
currencyCode: payment.currencyCode,
@@ -288,6 +306,7 @@ export class PaymentService {
async capturePayment(id: string, propertyId: string) {
const target = await this.findPaymentRow(id, propertyId);
this.assertGenericAccessAllowed(target);
+ const lifecycleOptions = await this.buildLifecycleGatewayOptions(target);
// Phase 1: atomically claim the payment (authorized → captured)
const [claimed] = await this.db
.update(payments)
@@ -321,7 +340,6 @@ export class PaymentService {
}
// Phase 2: call gateway outside the DB tx with an idempotency key
- const lifecycleOptions = await this.buildLifecycleGatewayOptions(claimed);
const result = await this.gateway.capture(
claimed.gatewayTransactionId,
new Decimal(claimed.amount).toNumber(),
@@ -360,6 +378,7 @@ export class PaymentService {
async voidPayment(id: string, propertyId: string) {
const target = await this.findPaymentRow(id, propertyId);
this.assertGenericAccessAllowed(target);
+ const lifecycleOptions = await this.buildLifecycleGatewayOptions(target);
// Phase 1: atomically claim the payment (authorized → voided)
const [claimed] = await this.db
.update(payments)
@@ -386,10 +405,11 @@ export class PaymentService {
);
}
- const lifecycleOptions = await this.buildLifecycleGatewayOptions(claimed);
const result = await this.gateway.void(claimed.gatewayTransactionId, {
idempotencyKey: `void_${id}`,
...lifecycleOptions,
+ currencyCode: claimed.currencyCode,
+ authorizedAmount: new Decimal(claimed.amount).toNumber(),
});
if (!result.success) {
diff --git a/apps/api/src/modules/payment/redsys-checkout-url.ts b/apps/api/src/modules/payment/redsys-checkout-url.ts
deleted file mode 100644
index 6ed51d62..00000000
--- a/apps/api/src/modules/payment/redsys-checkout-url.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * Embed a durable Redsys checkout token into browser return URLs so the
- * MemoryRouter booking widget can recover after a full-page redirect.
- */
-export function withRedsysCheckoutParams(
- url: string,
- checkoutToken: string,
- outcome: 'ok' | 'ko',
-): string {
- const parsed = new URL(url);
- parsed.searchParams.set('haip_checkout', checkoutToken);
- parsed.searchParams.set('redsys', outcome);
- return parsed.toString();
-}
diff --git a/apps/api/src/modules/payment/redsys-ci.spec.ts b/apps/api/src/modules/payment/redsys-ci.spec.ts
new file mode 100644
index 00000000..a73da729
--- /dev/null
+++ b/apps/api/src/modules/payment/redsys-ci.spec.ts
@@ -0,0 +1,12 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+describe('Redsys PostgreSQL regression gate', () => {
+ it.each(['ci.yml', 'release.yml'])('runs the callback suite against the disposable database in %s', (workflow) => {
+ const source = readFileSync(resolve(process.cwd(), '../../.github/workflows', workflow), 'utf8');
+ const tests = source.split(/\n {6}- name:/).find((step) => /Run tests/.test(step));
+ expect(tests).toMatch(/REDSYS_TEST_DATABASE_URL: postgresql:\/\/haip:haip@localhost:5432\/haip_test/);
+ expect(source).toMatch(/POSTGRES_DB: haip_test/);
+ });
+});
diff --git a/apps/api/src/modules/payment/redsys-credentials.integration.spec.ts b/apps/api/src/modules/payment/redsys-credentials.integration.spec.ts
new file mode 100644
index 00000000..ecccc730
--- /dev/null
+++ b/apps/api/src/modules/payment/redsys-credentials.integration.spec.ts
@@ -0,0 +1,125 @@
+import { randomUUID } from 'node:crypto';
+import { and, eq } from 'drizzle-orm';
+import { drizzle } from 'drizzle-orm/postgres-js';
+import postgres from 'postgres';
+import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { auditLogs, integrationCatalogEntries, properties, propertyIntegrations } from '@telivityhaip/database';
+import { IntegrationsService } from '../integrations/integrations.service';
+import { PropertyIntegrationsController } from '../integrations/property-integrations.controller';
+import { RedsysCredentialsService } from './redsys-credentials.service';
+
+const databaseUrl = process.env.REDSYS_TEST_DATABASE_URL;
+describe.skipIf(!databaseUrl)('Redsys protected property credentials', () => {
+ const client = postgres(databaseUrl ?? 'postgresql://localhost/unavailable');
+ const db = drizzle(client);
+ const service = new IntegrationsService(db);
+ const controller = new PropertyIntegrationsController(service);
+ const resolver = new RedsysCredentialsService(service, { get: () => undefined } as any);
+ const secret = 'synthetic-signing-secret-only';
+ let propertyId: string;
+
+ beforeEach(async () => {
+ vi.stubEnv('MIGRATION_CREDENTIAL_ENCRYPTION_KEY', '12'.repeat(32));
+ vi.stubEnv('MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID', 'default');
+ vi.stubEnv('MIGRATION_CREDENTIAL_ENCRYPTION_KEYS', '');
+ propertyId = randomUUID();
+ await db.insert(properties).values({ id: propertyId, name: 'Credential test', code: propertyId.slice(0, 20), countryCode: 'ES', timezone: 'Europe/Madrid', currencyCode: 'EUR', totalRooms: 1 });
+ await db.insert(integrationCatalogEntries).values({ slug: 'redsys', category: 'Payments', name: 'Redsys', status: 'shipped', description: 'Redsys' }).onConflictDoNothing();
+ });
+ afterEach(async () => {
+ vi.unstubAllEnvs();
+ await db.delete(auditLogs).where(eq(auditLogs.propertyId, propertyId));
+ await db.delete(propertyIntegrations).where(eq(propertyIntegrations.propertyId, propertyId));
+ await db.delete(properties).where(eq(properties.id, propertyId));
+ });
+ afterAll(async () => { await client.end(); });
+
+ async function stored() {
+ const [row] = await db.select().from(propertyIntegrations).where(and(eq(propertyIntegrations.propertyId, propertyId), eq(propertyIntegrations.catalogSlug, 'redsys')));
+ return row;
+ }
+
+ it('encrypts signing keys at rest and resolves them only for the owning property', async () => {
+ const response = await controller.upsert('redsys', propertyId, { enabled: true, config: { merchantCode: '999008881', terminal: '001', secretKey: secret } }, {});
+ const row = await stored();
+ expect(JSON.stringify(row.config)).not.toContain(secret);
+ expect(row.config.secretKeyEncrypted).toEqual(expect.objectContaining({ keyId: 'default', ciphertext: expect.any(String), authTag: expect.any(String) }));
+ expect(JSON.stringify(response)).not.toContain(secret);
+ expect(response.config).not.toHaveProperty('secretKeyEncrypted');
+ expect((await resolver.resolveForProperty(propertyId))?.secretKey).toBe(secret);
+ expect(await resolver.resolveForProperty(randomUUID())).toBeNull();
+ const audit = await db.select().from(auditLogs).where(eq(auditLogs.propertyId, propertyId));
+ expect(JSON.stringify(audit, (_key, value) => typeof value === 'bigint' ? value.toString() : value)).not.toContain(secret);
+ });
+
+ it.each(['secretKey', 'secret_key', 'clave'])('canonicalizes %s, masks every public response and preserves blank updates', async (key) => {
+ const response = await controller.upsert('redsys', propertyId, { enabled: true, config: { merchantCode: '999008881', [key]: secret } }, {});
+ const original = await stored();
+ for (const item of [response, await controller.getOne('redsys', propertyId), ...(await controller.list(propertyId))]) {
+ expect(JSON.stringify(item)).not.toContain(secret);
+ for (const spelling of ['secretKey', 'secret_key', 'clave', 'secretKeyEncrypted']) expect(item.config).not.toHaveProperty(spelling);
+ }
+ expect(original.config).not.toHaveProperty(key);
+ expect(original.config.secretKeyEncrypted).toBeDefined();
+ await controller.upsert('redsys', propertyId, { enabled: false, config: { [key]: ' ', secretKeyMasked: 'ignore' } }, {});
+ expect((await stored()).config).toEqual(original.config);
+ await controller.upsert('redsys', propertyId, { enabled: true }, {});
+ expect((await resolver.resolveForProperty(propertyId))?.secretKey).toBe(secret);
+ });
+
+ it.each(['secretKey', 'secret_key', 'clave'])('masks legacy %s from list/get without needing the encryption key', async (key) => {
+ await db.insert(propertyIntegrations).values({ propertyId, catalogSlug: 'redsys', config: { [key]: secret } });
+ vi.stubEnv('MIGRATION_CREDENTIAL_ENCRYPTION_KEY', '');
+ for (const item of [await controller.getOne('redsys', propertyId), ...(await controller.list(propertyId))]) {
+ expect(JSON.stringify(item)).not.toContain(secret);
+ expect(item.config).not.toHaveProperty(key);
+ }
+ });
+
+ it.each(['secretKey', 'secret_key', 'clave'])('upgrades legacy %s before resolving without changing another property', async (key) => {
+ await db.insert(propertyIntegrations).values({ propertyId, catalogSlug: 'redsys', config: { merchantCode: '999008881', [key]: secret } });
+ expect(await resolver.resolveForProperty(randomUUID())).toBeNull();
+ expect((await stored()).config[key]).toBe(secret);
+ expect((await resolver.resolveForProperty(propertyId))?.secretKey).toBe(secret);
+ const row = await stored();
+ expect(JSON.stringify(row.config)).not.toContain(secret);
+ expect(row.config.secretKeyEncrypted).toBeDefined();
+ const encrypted = row.config.secretKeyEncrypted;
+ await resolver.resolveForProperty(propertyId);
+ expect((await stored()).config.secretKeyEncrypted).toEqual(encrypted);
+ });
+
+ it('rejects caller-supplied ciphertext without overwriting stored credentials', async () => {
+ await controller.upsert('redsys', propertyId, { enabled: true, config: { merchantCode: '999008881', secretKey: secret } }, {});
+ const original = (await stored()).config;
+ await expect(controller.upsert('redsys', propertyId, { enabled: true, config: { secretKeyEncrypted: { ciphertext: 'forged' } } }, {})).rejects.toThrow(/ciphertext/i);
+ expect((await stored()).config).toEqual(original);
+ });
+
+ it('fails closed with unavailable encryption keys even when environment merchant credentials exist', async () => {
+ await controller.upsert('redsys', propertyId, { enabled: true, config: { merchantCode: '999008881', secretKey: secret } }, {});
+ vi.stubEnv('MIGRATION_CREDENTIAL_ENCRYPTION_KEY', '');
+ const withFallback = new RedsysCredentialsService(service, { get: (key: string) => ({ REDSYS_MERCHANT_CODE: '111111111', REDSYS_SECRET_KEY: 'different-merchant-secret' })[key] } as any);
+ await expect(withFallback.resolveForProperty(propertyId)).rejects.toThrow(/credentials.*unavailable/i);
+ });
+
+ it('leaves stored credentials unchanged when replacement encryption is unavailable', async () => {
+ await controller.upsert('redsys', propertyId, { enabled: true, config: { merchantCode: '999008881', secretKey: secret } }, {});
+ const original = (await stored()).config;
+ vi.stubEnv('MIGRATION_CREDENTIAL_ENCRYPTION_KEY', '');
+ await expect(controller.upsert('redsys', propertyId, { enabled: true, config: { secretKey: 'replacement-test-key' } }, {})).rejects.toThrow(/not configured/);
+ expect((await stored()).config).toEqual(original);
+ });
+
+ it('decrypts a rotated legacy key and refuses tampered ciphertext', async () => {
+ await controller.upsert('redsys', propertyId, { enabled: true, config: { merchantCode: '999008881', secretKey: secret } }, {});
+ vi.stubEnv('MIGRATION_CREDENTIAL_ENCRYPTION_KEY', '34'.repeat(32));
+ vi.stubEnv('MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID', 'current');
+ vi.stubEnv('MIGRATION_CREDENTIAL_ENCRYPTION_KEYS', JSON.stringify({ default: '12'.repeat(32) }));
+ expect((await resolver.resolveForProperty(propertyId))?.secretKey).toBe(secret);
+ const config = (await stored()).config;
+ await db.update(propertyIntegrations).set({ config: { ...config, secretKeyEncrypted: { ...(config.secretKeyEncrypted as object), authTag: '00'.repeat(16) } } })
+ .where(and(eq(propertyIntegrations.propertyId, propertyId), eq(propertyIntegrations.catalogSlug, 'redsys')));
+ await expect(resolver.resolveForProperty(propertyId)).rejects.toThrow(/credentials.*unavailable/i);
+ });
+});
diff --git a/apps/api/src/modules/payment/redsys-credentials.service.ts b/apps/api/src/modules/payment/redsys-credentials.service.ts
index 82f83ae0..f14db411 100644
--- a/apps/api/src/modules/payment/redsys-credentials.service.ts
+++ b/apps/api/src/modules/payment/redsys-credentials.service.ts
@@ -1,7 +1,8 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IntegrationsService } from '../integrations/integrations.service';
import type { RedsysMerchantCredentials } from './gateways/redsys-gateway';
+import { decryptCredentialPlaintext, deserializeEncryptedBlob, loadMigrationCredentialKeyRingFromEnv } from '../../common/crypto/credential-encryption';
/**
* Resolves Redsys FUC / terminal / secret for a property.
@@ -18,6 +19,7 @@ export class RedsysCredentialsService {
propertyId: string,
): Promise {
try {
+ await this.integrationsService.protectRedsysCredentials(propertyId);
const connection = await this.integrationsService.getPropertyIntegration(
propertyId,
'redsys',
@@ -30,7 +32,9 @@ export class RedsysCredentialsService {
'merchant_code',
'fuc',
);
- const secretKey = stringField(cfg, 'secretKey', 'secret_key', 'clave');
+ const secretKey = cfg['secretKeyEncrypted']
+ ? decryptCredentialPlaintext(deserializeEncryptedBlob(JSON.stringify(cfg['secretKeyEncrypted'])), loadMigrationCredentialKeyRingFromEnv())
+ : stringField(cfg, 'secretKey', 'secret_key', 'clave');
const terminal = stringField(cfg, 'terminal') || '001';
const environmentRaw = (
stringField(cfg, 'environment', 'env') || 'test'
@@ -44,8 +48,12 @@ export class RedsysCredentialsService {
};
}
}
- } catch {
- // Catalog row may be missing before seed — fall through to env.
+ } catch (error) {
+ // Only a missing catalog permits env fallback. Invalid ciphertext, missing
+ // encryption keys or database errors must never select a different merchant.
+ if (!(error instanceof NotFoundException)) {
+ throw new ServiceUnavailableException('Redsys credentials are unavailable');
+ }
}
const merchantCode = this.configService
@@ -69,11 +77,7 @@ export class RedsysCredentialsService {
}
publicApiBaseUrl(): string {
- const base =
- this.configService.get('PUBLIC_API_BASE_URL')?.trim() ||
- this.configService.get('API_BASE_URL')?.trim() ||
- 'http://localhost:3000';
- return base.replace(/\/$/, '');
+ return publicApiBaseUrl(this.configService);
}
merchantNotificationUrl(): string {
@@ -81,6 +85,13 @@ export class RedsysCredentialsService {
}
}
+/** Shared trusted origin/prefix for the provider notification and browser relay. */
+export function publicApiBaseUrl(config: ConfigService): string {
+ const base = config.get('PUBLIC_API_BASE_URL')?.trim()
+ || config.get('API_BASE_URL')?.trim() || 'http://localhost:3000';
+ return base.replace(/\/$/, '');
+}
+
function stringField(
cfg: Record,
...keys: string[]
diff --git a/apps/api/src/modules/payment/redsys-payment-finalizer.service.spec.ts b/apps/api/src/modules/payment/redsys-payment-finalizer.service.spec.ts
deleted file mode 100644
index d7af457e..00000000
--- a/apps/api/src/modules/payment/redsys-payment-finalizer.service.spec.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { RedsysPaymentFinalizer } from './redsys-payment-finalizer.service';
-
-function makeFinalizer() {
- const paymentRow = {
- id: 'pay-1',
- propertyId: 'prop-1',
- folioId: 'folio-1',
- status: 'pending',
- amount: '110.00',
- currencyCode: 'EUR',
- gatewayTransactionId: '1234ORDER01',
- notes: null,
- };
-
- const db = {
- update: vi.fn().mockReturnValue({
- set: vi.fn().mockReturnValue({
- where: vi.fn().mockReturnValue({
- returning: vi.fn().mockResolvedValue([{ ...paymentRow, status: 'authorized' }]),
- }),
- }),
- }),
- select: vi.fn(),
- };
-
- // Chain helpers for select().from().where().limit()
- const selectChain = {
- from: vi.fn().mockReturnThis(),
- where: vi.fn().mockReturnThis(),
- limit: vi.fn(),
- };
- db.select.mockReturnValue(selectChain);
-
- const webhookService = { emit: vi.fn().mockResolvedValue(undefined) };
- const depositService = { recordDeposit: vi.fn().mockResolvedValue({ id: 'dep-1' }) };
- const reservationService = { confirm: vi.fn().mockResolvedValue({ id: 'res-1', status: 'confirmed' }) };
-
- const finalizer = new RedsysPaymentFinalizer(
- db as any,
- webhookService as any,
- depositService as any,
- reservationService as any,
- );
-
- return {
- finalizer,
- db,
- selectChain,
- webhookService,
- depositService,
- reservationService,
- paymentRow,
- };
-}
-
-describe('RedsysPaymentFinalizer', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it('on success: authorizes, records deposit once, and auto-confirms when configured', async () => {
- const {
- finalizer,
- selectChain,
- depositService,
- reservationService,
- paymentRow,
- } = makeFinalizer();
-
- // folio lookup, existing deposit miss, booking config (refundable), booking config (autoConfirm), reservation
- selectChain.limit
- .mockResolvedValueOnce([{ id: 'folio-1', reservationId: 'res-1', propertyId: 'prop-1' }])
- .mockResolvedValueOnce([]) // no existing deposit
- .mockResolvedValueOnce([{ depositPolicy: { refundable: true } }])
- .mockResolvedValueOnce([{ autoConfirm: true }])
- .mockResolvedValueOnce([{ id: 'res-1', status: 'pending' }]);
-
- await finalizer.finalizeVerifiedNotification({
- payment: paymentRow as any,
- success: true,
- dsResponse: '0000',
- });
-
- expect(depositService.recordDeposit).toHaveBeenCalledOnce();
- expect(reservationService.confirm).toHaveBeenCalledWith('res-1', 'prop-1');
- });
-
- it('on success replay: skips duplicate deposit when ledger row already exists', async () => {
- const {
- finalizer,
- selectChain,
- depositService,
- reservationService,
- paymentRow,
- db,
- } = makeFinalizer();
-
- // Already authorized — markAuthorized short-circuits
- const authorized = { ...paymentRow, status: 'authorized' };
- db.update.mockReturnValue({
- set: vi.fn().mockReturnValue({
- where: vi.fn().mockReturnValue({
- returning: vi.fn().mockResolvedValue([]),
- }),
- }),
- });
-
- selectChain.limit
- .mockResolvedValueOnce([authorized]) // fresh reload after empty update
- .mockResolvedValueOnce([{ id: 'folio-1', reservationId: 'res-1', propertyId: 'prop-1' }])
- .mockResolvedValueOnce([{ id: 'dep-existing' }]) // existing deposit
- .mockResolvedValueOnce([{ autoConfirm: true }])
- .mockResolvedValueOnce([{ id: 'res-1', status: 'confirmed' }]); // already confirmed
-
- await finalizer.finalizeVerifiedNotification({
- payment: authorized as any,
- success: true,
- dsResponse: '0000',
- });
-
- expect(depositService.recordDeposit).not.toHaveBeenCalled();
- expect(reservationService.confirm).not.toHaveBeenCalled();
- });
-
- it('on failure: marks payment failed and never records deposit or confirms', async () => {
- const {
- finalizer,
- depositService,
- reservationService,
- paymentRow,
- webhookService,
- } = makeFinalizer();
-
- await finalizer.finalizeVerifiedNotification({
- payment: paymentRow as any,
- success: false,
- dsResponse: '0190',
- });
-
- expect(depositService.recordDeposit).not.toHaveBeenCalled();
- expect(reservationService.confirm).not.toHaveBeenCalled();
- expect(webhookService.emit).toHaveBeenCalledWith(
- 'payment.failed',
- 'payment',
- 'pay-1',
- expect.any(Object),
- 'prop-1',
- );
- });
-});
diff --git a/apps/api/src/modules/payment/redsys-payment-finalizer.service.ts b/apps/api/src/modules/payment/redsys-payment-finalizer.service.ts
deleted file mode 100644
index d8e0dfe4..00000000
--- a/apps/api/src/modules/payment/redsys-payment-finalizer.service.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-import { Inject, Injectable, Logger, forwardRef } from '@nestjs/common';
-import { and, eq } from 'drizzle-orm';
-import {
- bookingEngineConfig,
- depositLedgerEntries,
- folios,
- payments,
- reservations,
-} from '@telivityhaip/database';
-import { DRIZZLE } from '../../database/database.module';
-import { WebhookService } from '../webhook/webhook.service';
-import { DepositService } from '../accounting/deposit.service';
-import { ReservationService } from '../reservation/reservation.service';
-
-type PaymentRow = typeof payments.$inferSelect;
-
-/**
- * Idempotent post-authorization finalizer for Redsys MerchantURL notifications.
- *
- * Browser URLOK/URLKO is navigation only. Signature-verified provider results
- * own payment state, deposit ledger creation, and booking-engine auto-confirm.
- */
-@Injectable()
-export class RedsysPaymentFinalizer {
- private readonly logger = new Logger(RedsysPaymentFinalizer.name);
-
- constructor(
- @Inject(DRIZZLE) private readonly db: any,
- private readonly webhookService: WebhookService,
- private readonly depositService: DepositService,
- @Inject(forwardRef(() => ReservationService))
- private readonly reservationService: ReservationService,
- ) {}
-
- async finalizeVerifiedNotification(input: {
- payment: PaymentRow;
- success: boolean;
- dsResponse?: string;
- }): Promise {
- const { payment, success, dsResponse } = input;
-
- if (!success) {
- await this.markFailed(payment, dsResponse);
- return;
- }
-
- const authorized = await this.markAuthorized(payment, dsResponse);
- const current = authorized ?? payment;
- if (current.status !== 'authorized' && current.status !== 'captured') {
- // Lost the pending→authorized race to another writer with a non-success path.
- return;
- }
-
- await this.ensureDepositAndConfirm(current);
- }
-
- private async markAuthorized(
- payment: PaymentRow,
- dsResponse?: string,
- ): Promise {
- if (payment.status === 'authorized' || payment.status === 'captured') {
- return payment;
- }
-
- const [updated] = await this.db
- .update(payments)
- .set({
- status: 'authorized',
- notes: payment.notes
- ? `${payment.notes}; redsys Ds_Response=${dsResponse}`
- : `redsys Ds_Response=${dsResponse}`,
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(payments.id, payment.id),
- eq(payments.propertyId, payment.propertyId),
- eq(payments.status, 'pending'),
- ),
- )
- .returning();
-
- if (updated) {
- await this.webhookService.emit(
- 'payment.received',
- 'payment',
- updated.id,
- {
- folioId: updated.folioId,
- status: 'authorized',
- amount: updated.amount,
- gatewayProvider: 'redsys',
- },
- updated.propertyId,
- );
- this.logger.log(
- `Redsys payment=${updated.id} authorized order=${updated.gatewayTransactionId}`,
- );
- return updated;
- }
-
- const [fresh] = await this.db
- .select()
- .from(payments)
- .where(
- and(
- eq(payments.id, payment.id),
- eq(payments.propertyId, payment.propertyId),
- ),
- )
- .limit(1);
- return fresh ?? null;
- }
-
- private async markFailed(payment: PaymentRow, dsResponse?: string): Promise {
- if (payment.status !== 'pending') {
- return;
- }
-
- const [updated] = await this.db
- .update(payments)
- .set({
- status: 'failed',
- notes: `redsys Ds_Response=${dsResponse ?? 'unknown'}`,
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(payments.id, payment.id),
- eq(payments.propertyId, payment.propertyId),
- eq(payments.status, 'pending'),
- ),
- )
- .returning();
-
- if (!updated) {
- return;
- }
-
- await this.webhookService.emit(
- 'payment.failed',
- 'payment',
- payment.id,
- {
- folioId: payment.folioId,
- error: `Ds_Response=${dsResponse ?? 'unknown'}`,
- },
- payment.propertyId,
- );
- this.logger.warn(
- `Redsys payment=${payment.id} failed Ds_Response=${dsResponse}`,
- );
- }
-
- private async ensureDepositAndConfirm(payment: PaymentRow): Promise {
- if (!payment.folioId) {
- this.logger.warn(
- `Redsys payment=${payment.id} has no folio — skipping deposit/confirm`,
- );
- return;
- }
-
- const [folio] = await this.db
- .select()
- .from(folios)
- .where(
- and(
- eq(folios.id, payment.folioId),
- eq(folios.propertyId, payment.propertyId),
- ),
- )
- .limit(1);
-
- if (!folio?.reservationId) {
- this.logger.warn(
- `Redsys payment=${payment.id} folio=${payment.folioId} has no reservation — skipping deposit/confirm`,
- );
- return;
- }
-
- const [existingDeposit] = await this.db
- .select({ id: depositLedgerEntries.id })
- .from(depositLedgerEntries)
- .where(
- and(
- eq(depositLedgerEntries.paymentId, payment.id),
- eq(depositLedgerEntries.propertyId, payment.propertyId),
- ),
- )
- .limit(1);
-
- if (!existingDeposit) {
- const [cfg] = await this.db
- .select()
- .from(bookingEngineConfig)
- .where(eq(bookingEngineConfig.propertyId, payment.propertyId))
- .limit(1);
- const refundable =
- (cfg?.depositPolicy as { refundable?: boolean } | null)?.refundable ??
- true;
-
- await this.depositService.recordDeposit({
- propertyId: payment.propertyId,
- reservationId: folio.reservationId,
- paymentId: payment.id,
- amount: String(payment.amount),
- currencyCode: payment.currencyCode,
- isRefundable: refundable,
- } as any);
- }
-
- const [cfg] = await this.db
- .select({ autoConfirm: bookingEngineConfig.autoConfirm })
- .from(bookingEngineConfig)
- .where(eq(bookingEngineConfig.propertyId, payment.propertyId))
- .limit(1);
-
- if (!cfg?.autoConfirm) {
- return;
- }
-
- const [reservation] = await this.db
- .select({ id: reservations.id, status: reservations.status })
- .from(reservations)
- .where(
- and(
- eq(reservations.id, folio.reservationId),
- eq(reservations.propertyId, payment.propertyId),
- ),
- )
- .limit(1);
-
- if (!reservation || reservation.status !== 'pending') {
- return;
- }
-
- try {
- await this.reservationService.confirm(reservation.id, payment.propertyId);
- this.logger.log(
- `Redsys auto-confirmed reservation=${reservation.id} payment=${payment.id}`,
- );
- } catch (err) {
- // Idempotent confirm may race; log and continue.
- this.logger.warn(
- `Redsys auto-confirm skipped for reservation=${reservation.id}: ${String(err)}`,
- );
- }
- }
-}
diff --git a/apps/api/src/modules/payment/redsys-webhook.controller.spec.ts b/apps/api/src/modules/payment/redsys-webhook.controller.spec.ts
new file mode 100644
index 00000000..06a38d50
--- /dev/null
+++ b/apps/api/src/modules/payment/redsys-webhook.controller.spec.ts
@@ -0,0 +1,257 @@
+import { randomUUID } from 'node:crypto';
+import { EventEmitter2 } from '@nestjs/event-emitter';
+import { and, eq } from 'drizzle-orm';
+import { drizzle } from 'drizzle-orm/postgres-js';
+import postgres from 'postgres';
+import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ auditLogs, bookings, depositLedgerEntries, folios, guests, payments,
+ properties, ratePlans, reservations, roomTypes,
+} from '@telivityhaip/database';
+import { WebhookService } from '../webhook/webhook.service';
+import { RedsysWebhookController } from './redsys-webhook.controller';
+import { BookingReturnService } from '../booking-engine/booking-return.service';
+import { REDSYS_SANDBOX, REDSYS_SIGNATURE_VERSION, encodeMerchantParameters, signMerchantParameters } from './gateways/redsys-crypto';
+
+// Opt-in PostgreSQL suite. Every fixture uses fresh IDs; cleanup only removes
+// rows belonging to that fixture, never other users' or suites' records.
+const databaseUrl = process.env['REDSYS_TEST_DATABASE_URL'];
+describe.skipIf(!databaseUrl)('Redsys authoritative booking finalization', () => {
+ const client = postgres(databaseUrl ?? 'postgresql://localhost/unavailable', { max: 5 });
+ const db = drizzle(client);
+ const events = new EventEmitter2();
+ const webhook = new WebhookService(db, events);
+ const credentials = { resolveForProperty: vi.fn(async () => ({ ...REDSYS_SANDBOX, environment: 'test' })) };
+ const controller = new RedsysWebhookController(db, webhook, credentials as any);
+ let propertyId: string;
+ let guestId: string;
+ let reservationId: string;
+ let paymentId: string;
+ let orderId: string;
+
+ beforeEach(async () => {
+ propertyId = randomUUID();
+ guestId = randomUUID();
+ reservationId = randomUUID();
+ paymentId = randomUUID();
+ orderId = `1234${randomUUID().replaceAll('-', '').slice(0, 8)}`;
+ const roomTypeId = randomUUID();
+ const ratePlanId = randomUUID();
+ const bookingId = randomUUID();
+ const folioId = randomUUID();
+ await db.insert(properties).values({ id: propertyId, name: 'Payment test', code: propertyId.slice(0, 20), countryCode: 'ES', timezone: 'Europe/Madrid', currencyCode: 'EUR', totalRooms: 1 });
+ await db.insert(guests).values({ id: guestId, firstName: 'Test', lastName: 'Guest' });
+ await db.insert(roomTypes).values({ id: roomTypeId, propertyId, name: 'Test', code: 'TEST', maxOccupancy: 2, defaultOccupancy: 1 });
+ await db.insert(ratePlans).values({ id: ratePlanId, propertyId, roomTypeId, name: 'Test', code: 'TEST', type: 'bar', baseAmount: '110.00', currencyCode: 'EUR' });
+ await db.insert(bookings).values({ id: bookingId, propertyId, guestId, confirmationNumber: randomUUID(), source: 'direct', channelCode: 'booking_engine' });
+ await db.insert(reservations).values({ id: reservationId, propertyId, bookingId, guestId, roomTypeId, ratePlanId, arrivalDate: '2027-01-01', departureDate: '2027-01-02', nights: 1, totalAmount: '110.00', currencyCode: 'EUR' });
+ await db.insert(folios).values({ id: folioId, propertyId, reservationId, bookingId, guestId, folioNumber: randomUUID(), currencyCode: 'EUR' });
+ await db.insert(payments).values({ id: paymentId, propertyId, folioId, method: 'credit_card', status: 'pending', amount: '110.00', currencyCode: 'EUR', isPreAuthorization: true, gatewayProvider: 'redsys', gatewayTransactionId: orderId,
+ authorizationFinalization: { deposit: { reservationId, isRefundable: false, autoConfirm: true } },
+ } as any);
+ });
+
+ afterEach(async () => {
+ events.removeAllListeners();
+ vi.restoreAllMocks();
+ for (const table of [auditLogs, depositLedgerEntries, payments, folios, reservations, bookings, ratePlans, roomTypes]) {
+ await db.delete(table).where(eq(table.propertyId, propertyId));
+ }
+ await db.delete(guests).where(eq(guests.id, guestId));
+ await db.delete(properties).where(eq(properties.id, propertyId));
+ });
+ afterAll(async () => { await client.end(); });
+
+ function notification(overrides: Record = {}) {
+ const encoded = encodeMerchantParameters({ Ds_Order: orderId, Ds_Response: '0000', Ds_Amount: '11000', Ds_Currency: '978', Ds_MerchantCode: REDSYS_SANDBOX.merchantCode, Ds_Terminal: '1', Ds_TransactionType: '1', ...overrides });
+ return { body: { Ds_SignatureVersion: REDSYS_SIGNATURE_VERSION, Ds_MerchantParameters: encoded, Ds_Signature: signMerchantParameters(encoded, REDSYS_SANDBOX.secretKey, orderId) } };
+ }
+ async function notify(req = notification()) {
+ const res = { status: vi.fn().mockReturnThis(), send: vi.fn().mockReturnThis() };
+ await controller.handleNotification(req, res);
+ return res.status.mock.calls[0]?.[0];
+ }
+ async function state() {
+ const [payment] = await db.select().from(payments).where(and(eq(payments.id, paymentId), eq(payments.propertyId, propertyId)));
+ const [reservation] = await db.select().from(reservations).where(and(eq(reservations.id, reservationId), eq(reservations.propertyId, propertyId)));
+ const deposits = await db.select().from(depositLedgerEntries).where(eq(depositLedgerEntries.propertyId, propertyId));
+ const audits = await db.select().from(auditLogs).where(eq(auditLogs.propertyId, propertyId));
+ return { payment, reservation, deposits, audits };
+ }
+
+ function returnService() {
+ return new BookingReturnService(db, { get: (key: string) => ({
+ BOOKING_RETURN_ORIGINS: 'https://hotel.example', PUBLIC_API_BASE_URL: 'https://api.example', NODE_ENV: 'production',
+ })[key] } as any);
+ }
+
+ it.each([['110', 200, 'authorized'], ['11000', 400, 'pending']] as const)(
+ 'compares JPY authorization using its whole-unit amount %s', async (amount, status, paymentStatus) => {
+ await db.update(payments).set({ currencyCode: 'JPY' })
+ .where(and(eq(payments.id, paymentId), eq(payments.propertyId, propertyId)));
+ expect(await notify(notification({ Ds_Amount: amount, Ds_Currency: '392' }))).toBe(status);
+ const result = await state();
+ expect(result.payment.status).toBe(paymentStatus);
+ expect(result.deposits).toHaveLength(status === 200 ? 1 : 0);
+ },
+ );
+
+ it('recovers payment state before and after the callback using only a scoped return capability', async () => {
+ const returns = returnService();
+ const destination = `https://hotel.example/stays/book?lang=es&context=${'a'.repeat(500)}#rooms`;
+ const prepared = returns.prepare(propertyId, destination);
+ const reference = new URL(prepared.url).pathname.split('/').at(-1)!;
+ await db.update(payments).set({ bookingReturnReferenceHash: prepared.referenceHash, bookingReturnDestination: prepared.destination })
+ .where(and(eq(payments.id, paymentId), eq(payments.propertyId, propertyId)));
+ const target = new URL(destination);
+ target.searchParams.set('haip_payment_return', reference);
+ expect(prepared.url.length).toBeLessThanOrEqual(250);
+ expect(await returns.resolve(propertyId, reference)).toBe(target.href);
+ await expect(returns.resolve(randomUUID(), reference)).rejects.toThrow('Payment return not found');
+ await expect(returns.resolve(propertyId, 'z'.repeat(43))).rejects.toThrow('Payment return not found');
+ expect(await returns.status(propertyId, reference)).toEqual({ status: 'processing' });
+ await expect(returns.status(randomUUID(), reference)).rejects.toThrow('Payment return not found');
+ await expect(returns.status(propertyId, 'z'.repeat(43))).rejects.toThrow('Payment return not found');
+ await notify();
+ expect(await returns.status(propertyId, reference)).toEqual({ status: 'succeeded' });
+ expect(await returns.resolve(propertyId, reference)).toBe(target.href);
+ expect((await state()).reservation?.status).toBe('confirmed');
+ });
+
+ it('keeps two valid capabilities bound to their own exact persisted targets', async () => {
+ const returns = returnService();
+ const first = returns.prepare(propertyId, 'https://hotel.example/stays/first?lang=es');
+ const second = returns.prepare(propertyId, 'https://hotel.example/stays/second?lang=en');
+ await db.update(payments).set({ bookingReturnReferenceHash: first.referenceHash, bookingReturnDestination: first.destination })
+ .where(and(eq(payments.id, paymentId), eq(payments.propertyId, propertyId)));
+ const existing = (await state()).payment!;
+ await db.insert(payments).values({ ...existing, id: randomUUID(), gatewayTransactionId: null,
+ bookingReturnReferenceHash: second.referenceHash, bookingReturnDestination: second.destination });
+ for (const prepared of [first, second]) {
+ const reference = new URL(prepared.url).pathname.split('/').at(-1)!;
+ const expected = new URL(prepared.destination);
+ expected.searchParams.set('haip_payment_return', reference);
+ expect(await returns.resolve(propertyId, reference)).toBe(expected.href);
+ }
+ });
+
+ it.each(['0180', '9915'])('shows failure only after the signed decline/cancellation callback %s', async (response) => {
+ const returns = returnService();
+ const prepared = returns.prepare(propertyId, 'https://hotel.example/stays/book');
+ const reference = new URL(prepared.url).pathname.split('/').at(-1)!;
+ await db.update(payments).set({ bookingReturnReferenceHash: prepared.referenceHash, bookingReturnDestination: prepared.destination })
+ .where(and(eq(payments.id, paymentId), eq(payments.propertyId, propertyId)));
+ expect(await returns.status(propertyId, reference)).toEqual({ status: 'processing' });
+ await notify(notification({ Ds_Response: response }));
+ expect(await returns.status(propertyId, reference)).toEqual({ status: 'failed' });
+ });
+
+ it('records the deposit and configured confirmation only after verified authorization', async () => {
+ expect((await state()).deposits).toHaveLength(0);
+ expect(await notify()).toBe(200);
+ const result = await state();
+ expect(result.payment?.status).toBe('authorized');
+ expect(result.deposits).toMatchObject([{ paymentId, reservationId, amount: '110.00', status: 'held', isRefundable: false }]);
+ expect(result.reservation?.status).toBe('confirmed');
+ expect(result.audits.map((row) => (row.newValue as any).event).sort()).toEqual(['deposit.received', 'payment.received', 'reservation.confirmed']);
+ });
+
+ it('creates exactly one deposit and audit per event under simultaneous duplicate callbacks', async () => {
+ await Promise.all([notify(), notify(), notify()]);
+ await notify();
+ const result = await state();
+ expect(result.deposits).toHaveLength(1);
+ expect(result.audits).toHaveLength(3);
+ expect(result.reservation?.status).toBe('confirmed');
+ });
+
+ it('does not auto-confirm when the saved configuration disables it', async () => {
+ await db.update(payments).set({ status: 'pending', authorizationFinalization: { deposit: { reservationId, isRefundable: true, autoConfirm: false } } } as any).where(and(eq(payments.id, paymentId), eq(payments.propertyId, propertyId)));
+ await notify();
+ const result = await state();
+ expect(result.deposits).toHaveLength(1);
+ expect(result.reservation?.status).toBe('pending');
+ });
+
+ it('does not infer a deposit from a booking folio without a saved intent', async () => {
+ await db.update(payments).set({ authorizationFinalization: null } as any)
+ .where(and(eq(payments.id, paymentId), eq(payments.propertyId, propertyId)));
+ await notify();
+ expect(await state()).toMatchObject({ payment: { status: 'authorized' }, reservation: { status: 'pending' }, deposits: [] });
+ });
+
+ it('rejects a deposit intent that does not belong to the payment folio', async () => {
+ await db.update(payments).set({ authorizationFinalization: { deposit: { reservationId: randomUUID(), isRefundable: true, autoConfirm: true } } } as any)
+ .where(and(eq(payments.id, paymentId), eq(payments.propertyId, propertyId)));
+ await expect(notify()).rejects.toThrow('Deposit reservation must match the payment folio');
+ expect(await state()).toMatchObject({ payment: { status: 'pending' }, reservation: { status: 'pending' }, deposits: [], audits: [] });
+ });
+
+ it('does not revive a cancelled reservation when authorization arrives late', async () => {
+ await db.update(reservations).set({ status: 'cancelled' })
+ .where(and(eq(reservations.id, reservationId), eq(reservations.propertyId, propertyId)));
+ await notify();
+ expect((await state()).reservation?.status).toBe('cancelled');
+ });
+
+ it('publishes each event once after the complete state and audits have committed', async () => {
+ const observed: Array<{ event: string; logicalEventId: string; status: string; deposits: number; audits: number }> = [];
+ for (const event of ['payment.received', 'deposit.received', 'reservation.confirmed']) {
+ events.on(event, async (payload) => {
+ const result = await state();
+ observed.push({ event, logicalEventId: payload.logicalEventId, status: result.reservation!.status, deposits: result.deposits.length, audits: result.audits.length });
+ });
+ }
+ await Promise.all([notify(), notify()]);
+ expect(observed).toHaveLength(3);
+ for (const item of observed) {
+ expect(item).toMatchObject({ status: 'confirmed', deposits: 1, audits: 3 });
+ expect((await state()).audits.some((audit) => audit.id === item.logicalEventId)).toBe(true);
+ }
+ });
+
+ it.each(['0190', '9915', '0400', '0900', '0000garbage'])('response %s cannot create a deposit or confirm', async (response) => {
+ await notify(notification({ Ds_Response: response }));
+ await notify(notification({ Ds_Response: response }));
+ const result = await state();
+ expect(result.payment?.status).toBe('failed');
+ expect(result.deposits).toHaveLength(0);
+ expect(result.reservation?.status).toBe('pending');
+ expect(result.audits).toHaveLength(1);
+ });
+
+ it('rejects an invalid signature without any changes', async () => {
+ const request = notification();
+ request.body.Ds_Signature = 'invalid';
+ expect(await notify(request)).toBe(400);
+ expect(await state()).toMatchObject({ payment: { status: 'pending' }, reservation: { status: 'pending' }, deposits: [], audits: [] });
+ });
+
+ it('rejects an unsupported signature version', async () => {
+ const request = notification();
+ request.body.Ds_SignatureVersion = 'unsupported';
+ expect(await notify(request)).toBe(400);
+ expect(await state()).toMatchObject({ payment: { status: 'pending' }, deposits: [], audits: [] });
+ });
+
+ it('rolls back all financial changes when persistence fails, allowing a clean retry', async () => {
+ const transaction = db.transaction.bind(db);
+ vi.spyOn(db, 'transaction').mockImplementationOnce((run: any) => transaction(async (tx) => {
+ const insert = tx.insert.bind(tx);
+ tx.insert = ((table: any) => {
+ if (table === auditLogs) throw new Error('simulated audit persistence failure');
+ return insert(table);
+ }) as any;
+ return run(tx);
+ }));
+ await expect(notify()).rejects.toThrow('simulated audit persistence failure');
+ expect(await state()).toMatchObject({ payment: { status: 'pending' }, reservation: { status: 'pending' }, deposits: [], audits: [] });
+ expect(await notify()).toBe(200);
+ expect((await state()).deposits).toHaveLength(1);
+ });
+
+ it.each([{ Ds_Amount: '1' }, { Ds_Currency: '840' }, { Ds_MerchantCode: 'other' }, { Ds_Terminal: '2' }, { Ds_TransactionType: '3' }])('rejects a signed notification that does not match the authorization: %j', async (override) => {
+ expect(await notify(notification(override))).toBe(400);
+ expect(await state()).toMatchObject({ payment: { status: 'pending' }, deposits: [], audits: [] });
+ });
+});
diff --git a/apps/api/src/modules/payment/redsys-webhook.controller.ts b/apps/api/src/modules/payment/redsys-webhook.controller.ts
index 41118400..aeada2b8 100644
--- a/apps/api/src/modules/payment/redsys-webhook.controller.ts
+++ b/apps/api/src/modules/payment/redsys-webhook.controller.ts
@@ -6,17 +6,23 @@ import {
Logger,
Inject,
HttpStatus,
+ BadRequestException,
} from '@nestjs/common';
+import { randomUUID } from 'node:crypto';
import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { and, eq } from 'drizzle-orm';
-import { payments } from '@telivityhaip/database';
+import Decimal from 'decimal.js';
+import { auditLogs, depositLedgerEntries, folios, payments, reservations } from '@telivityhaip/database';
import { Public } from '../auth/public.decorator';
import { DRIZZLE } from '../../database/database.module';
+import { WebhookService, type WebhookPayload } from '../webhook/webhook.service';
+import { assertTransition } from '../reservation/reservation-state-machine';
import { RedsysCredentialsService } from './redsys-credentials.service';
-import { RedsysPaymentFinalizer } from './redsys-payment-finalizer.service';
import {
decodeMerchantParameters,
- isRedsysSuccessResponse,
+ REDSYS_SIGNATURE_VERSION,
+ redsysAmountString,
+ redsysCurrencyCode,
verifyMerchantParametersSignature,
} from './gateways/redsys-crypto';
@@ -25,9 +31,7 @@ import {
*
* Redsys POSTs `application/x-www-form-urlencoded` with
* Ds_MerchantParameters, Ds_Signature, Ds_SignatureVersion.
- * Browser URLOK/URLKO alone must never authorize a payment — the signed
- * notification is the authority, and {@link RedsysPaymentFinalizer} owns
- * payment transition, deposit creation, and booking-engine auto-confirm.
+ * Browser URLOK/URLKO alone must never authorize a payment.
*/
@ApiTags('webhooks')
@Controller('webhooks/redsys')
@@ -36,8 +40,8 @@ export class RedsysWebhookController {
constructor(
@Inject(DRIZZLE) private readonly db: any,
+ private readonly webhookService: WebhookService,
private readonly credentialsService: RedsysCredentialsService,
- private readonly finalizer: RedsysPaymentFinalizer,
) {}
@Public()
@@ -52,10 +56,17 @@ export class RedsysWebhookController {
this.logger.warn('Redsys notification missing parameters or signature');
return res.status(HttpStatus.BAD_REQUEST).send('missing fields');
}
+ if (body.Ds_SignatureVersion !== REDSYS_SIGNATURE_VERSION) {
+ return res.status(HttpStatus.BAD_REQUEST).send('unsupported signature version');
+ }
let params: Record;
try {
params = decodeMerchantParameters(String(merchantParameters));
+ if (!params || typeof params !== 'object' || Array.isArray(params)
+ || Object.values(params).some((value) => typeof value !== 'string')) {
+ throw new Error('Expected string merchant parameters');
+ }
} catch (err) {
this.logger.warn(`Redsys notification decode failed: ${String(err)}`);
return res.status(HttpStatus.BAD_REQUEST).send('invalid parameters');
@@ -66,6 +77,8 @@ export class RedsysWebhookController {
return res.status(HttpStatus.BAD_REQUEST).send('missing order');
}
+ // Internal provider receiver: the order identifies the tenant before its
+ // property-specific signature is verified. All later queries are scoped.
const [payment] = await this.db
.select()
.from(payments)
@@ -107,15 +120,104 @@ export class RedsysWebhookController {
return res.status(HttpStatus.BAD_REQUEST).send('bad signature');
}
- const dsResponse = params['Ds_Response'] ?? params['DS_RESPONSE'];
- const success = isRedsysSuccessResponse(dsResponse);
+ const amount = params['Ds_Amount'] ?? params['DS_AMOUNT'] ?? '';
+ const terminal = params['Ds_Terminal'] ?? params['DS_TERMINAL'] ?? '';
+ if (!/^\d+$/.test(amount)
+ || !new Decimal(amount).equals(redsysAmountString(payment.amount, payment.currencyCode))
+ || (params['Ds_Currency'] ?? params['DS_CURRENCY']) !== redsysCurrencyCode(payment.currencyCode)
+ || (params['Ds_MerchantCode'] ?? params['DS_MERCHANTCODE']) !== creds.merchantCode
+ || !/^\d+$/.test(terminal) || Number(terminal) !== Number(creds.terminal)
+ || (params['Ds_TransactionType'] ?? params['DS_TRANSACTIONTYPE']) !== '1'
+ ) {
+ return res.status(HttpStatus.BAD_REQUEST).send('authorization mismatch');
+ }
+
+ const dsResponse = params['Ds_Response'] ?? params['DS_RESPONSE'] ?? '';
+ // This receiver completes preauthorization (type 1), not capture/refund/void.
+ const success = /^\d{4}$/.test(dsResponse) && Number(dsResponse) < 100;
+ await this.finalizeAuthorization(payment.id, payment.propertyId, dsResponse, success);
+ return res.status(HttpStatus.OK).send('OK');
+ }
+
+ private async finalizeAuthorization(
+ paymentId: string,
+ propertyId: string,
+ response: string,
+ success: boolean,
+ ): Promise {
+ const notifications: WebhookPayload[] = await this.db.transaction(async (tx: any) => {
+ // Conditional UPDATE locks the payment until commit. Competing callbacks
+ // then observe a terminal status and perform no deposit, audit or confirm.
+ const [payment] = await tx
+ .update(payments)
+ .set({
+ status: success ? 'authorized' : 'failed',
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(payments.id, paymentId),
+ eq(payments.propertyId, propertyId),
+ eq(payments.gatewayProvider, 'redsys'),
+ eq(payments.status, 'pending'),
+ ),
+ )
+ .returning();
+ if (!payment) return [];
- await this.finalizer.finalizeVerifiedNotification({
- payment,
- success,
- dsResponse,
+ const pendingEvents: WebhookPayload[] = [];
+ const addEvent = (event: WebhookPayload['event'], entityType: string, entityId: string, data: Record) => {
+ pendingEvents.push({ event, entityType, entityId, propertyId, data, timestamp: new Date().toISOString(), logicalEventId: randomUUID() });
+ };
+ addEvent(success ? 'payment.received' : 'payment.failed', 'payment', payment.id,
+ success
+ ? { folioId: payment.folioId, status: 'authorized', amount: payment.amount, gatewayProvider: 'redsys' }
+ : { folioId: payment.folioId, error: `Ds_Response=${response || 'unknown'}` });
+
+ const intent = payment.authorizationFinalization?.deposit;
+ if (success && intent) {
+ const [folio] = await tx.select().from(folios).where(and(eq(folios.id, payment.folioId), eq(folios.propertyId, propertyId)));
+ if (!folio || folio.reservationId !== intent.reservationId) {
+ throw new BadRequestException('Deposit reservation must match the payment folio');
+ }
+ const [reservation] = await tx.select().from(reservations)
+ .where(and(eq(reservations.id, intent.reservationId), eq(reservations.propertyId, propertyId)))
+ .for('update');
+ if (!reservation) throw new BadRequestException('Deposit reservation not found in this property');
+
+ // Same ownership, amount and held-liability invariants as recordDeposit;
+ // writes and their audit events share the payment's transaction.
+ const [deposit] = await tx.insert(depositLedgerEntries).values({
+ propertyId, reservationId: reservation.id, paymentId: payment.id,
+ amount: new Decimal(payment.amount).toFixed(2), currencyCode: payment.currencyCode,
+ status: 'held', isRefundable: intent.isRefundable,
+ }).returning();
+ addEvent('deposit.received', 'deposit', deposit.id, {
+ amount: deposit.amount, status: deposit.status, isRefundable: deposit.isRefundable,
+ });
+
+ // A payment arriving after cancellation must not resurrect the stay.
+ if (intent.autoConfirm && reservation.status === 'pending') {
+ assertTransition(reservation.status, 'confirmed');
+ await tx.update(reservations).set({ status: 'confirmed', updatedAt: new Date() })
+ .where(and(eq(reservations.id, reservation.id), eq(reservations.propertyId, propertyId), eq(reservations.status, 'pending')));
+ addEvent('reservation.confirmed', 'reservation', reservation.id, { status: 'confirmed' });
+ }
+ }
+
+ for (const payload of pendingEvents) {
+ await tx.insert(auditLogs).values({
+ id: payload.logicalEventId, propertyId, action: 'create',
+ entityType: payload.entityType, entityId: payload.entityId,
+ description: `Webhook event: ${payload.event}`, newValue: payload,
+ });
+ }
+ return pendingEvents;
});
- return res.status(HttpStatus.OK).send('OK');
+ // Consumers can only observe the completed payment/deposit/reservation set.
+ for (const payload of notifications) {
+ await this.webhookService.dispatchPersisted(payload, payload.logicalEventId!);
+ }
}
}
diff --git a/apps/api/src/modules/reservation/reservation.module.ts b/apps/api/src/modules/reservation/reservation.module.ts
index cf9b7892..b5ad378a 100644
--- a/apps/api/src/modules/reservation/reservation.module.ts
+++ b/apps/api/src/modules/reservation/reservation.module.ts
@@ -22,7 +22,7 @@ import { MigrationLegacyIdMapModule } from '../migration/migration-legacy-id-map
imports: [
forwardRef(() => FolioModule),
RoomModule,
- forwardRef(() => PaymentModule),
+ PaymentModule,
WebhookModule,
forwardRef(() => AncillaryModule),
AccountingModule,
diff --git a/apps/api/src/scripts/protect-redsys-credentials.ts b/apps/api/src/scripts/protect-redsys-credentials.ts
new file mode 100644
index 00000000..3475edcf
--- /dev/null
+++ b/apps/api/src/scripts/protect-redsys-credentials.ts
@@ -0,0 +1,27 @@
+import 'reflect-metadata';
+import { drizzle } from 'drizzle-orm/postgres-js';
+import postgres from 'postgres';
+import { properties } from '@telivityhaip/database';
+import { IntegrationsService } from '../modules/integrations/integrations.service';
+
+/** Deployment data migration. Reads connection/key-ring configuration only from the environment. */
+async function main() {
+ if (!process.env['DATABASE_URL']) throw new Error('Database configuration is required');
+ const client = postgres(process.env['DATABASE_URL'], { max: 1 });
+ try {
+ const db = drizzle(client);
+ const integrations = new IntegrationsService(db);
+ // Properties are tenants; every credential operation below is property-scoped.
+ const tenants = await db.select({ id: properties.id }).from(properties);
+ for (const tenant of tenants) await integrations.protectRedsysCredentials(tenant.id);
+ process.stdout.write(`Redsys credential protection completed for ${tenants.length} properties.\n`);
+ } finally {
+ await client.end();
+ }
+}
+
+void main().catch(() => {
+ // Never print a DB error, config value, ciphertext, or decrypted credential.
+ process.stderr.write('Redsys credential protection failed. Check database and credential key-ring configuration; rerun before accepting payments.\n');
+ process.exitCode = 1;
+});
diff --git a/apps/booking/src/App.tsx b/apps/booking/src/App.tsx
index a426fd88..61cfe609 100644
--- a/apps/booking/src/App.tsx
+++ b/apps/booking/src/App.tsx
@@ -6,6 +6,7 @@ import { RoomSelect } from './pages/RoomSelect';
import { Extras } from './pages/Extras';
import { GuestDetails } from './pages/GuestDetails';
import { Payment } from './pages/Payment';
+import { PaymentReturn } from './pages/PaymentReturn';
import { Confirmation } from './pages/Confirmation';
import { ManageBooking } from './pages/ManageBooking';
import { RequestApplication } from './pages/RequestApplication';
@@ -24,6 +25,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
{requestRoutesEnabled && (
<>
diff --git a/apps/booking/src/api/client.ts b/apps/booking/src/api/client.ts
index d13349cb..b04e47a4 100644
--- a/apps/booking/src/api/client.ts
+++ b/apps/booking/src/api/client.ts
@@ -6,7 +6,6 @@ import type {
BookingConfig,
BookingDetails,
CancelResponse,
- CheckoutStatus,
QuoteRequest,
QuoteResponse,
RequestPaymentMethodSetupRequest,
@@ -16,6 +15,7 @@ import type {
SellableServicesResponse,
SubmitBookingRequest,
BookingRequestAcknowledgement,
+ PaymentReturnStatus,
} from './types';
/**
@@ -49,6 +49,12 @@ export const api = axios.create({
setBookingKey(resolveBookingKey());
export const bookingApi = {
+ paymentReturnStatus: async (reference: string): Promise => {
+ const { data } = await api.get('/payment-return-status', {
+ headers: { 'x-payment-return-reference': reference },
+ });
+ return data;
+ },
config: async (): Promise => {
const { data } = await api.get('/config');
return data;
@@ -91,13 +97,6 @@ export const bookingApi = {
return data;
},
- getCheckout: async (checkoutToken: string): Promise => {
- const { data } = await api.get(
- `/checkouts/${encodeURIComponent(checkoutToken)}`,
- );
- 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 5ccfea66..840d83db 100644
--- a/apps/booking/src/api/types.ts
+++ b/apps/booking/src/api/types.ts
@@ -189,10 +189,12 @@ export interface BookRequest {
cardLastFour?: string;
cardBrand?: string;
serviceIds?: string[];
- /** Browser return URL after successful Redsys hosted checkout. */
- redirectUrlOk?: string;
- /** Browser return URL after failed/cancelled Redsys hosted checkout. */
- redirectUrlKo?: string;
+ /** Exact embedding page; the server validates and binds the return reference. */
+ returnUrl?: string;
+}
+
+export interface PaymentReturnStatus {
+ status: 'processing' | 'succeeded' | 'failed' | 'cancelled' | 'unavailable';
}
export interface BookResponse {
@@ -207,7 +209,6 @@ export interface BookResponse {
amount: string;
status: string;
nextAction?: PaymentNextAction;
- checkoutToken?: string | null;
} | null;
lineItems: QuoteLineItem[];
cancellationPolicy: string;
@@ -293,15 +294,3 @@ export interface SellableServicesResponse {
propertyId: string;
data: SellableService[];
}
-
-export interface CheckoutStatus {
- checkoutToken: string;
- confirmationNumber: string;
- reservationId: string;
- reservationStatus: string;
- paymentId: string;
- paymentStatus: string;
- depositStatus: string | null;
- amount: string;
- currencyCode: string;
-}
diff --git a/apps/booking/src/lib/paymentReturn.ts b/apps/booking/src/lib/paymentReturn.ts
new file mode 100644
index 00000000..3c5123b5
--- /dev/null
+++ b/apps/booking/src/lib/paymentReturn.ts
@@ -0,0 +1,6 @@
+/** Bootstrap the self-contained router after a full-page hosted checkout return. */
+export function paymentReturnEntry(href: string): string {
+ const reference = new URL(href).searchParams.get('haip_payment_return');
+ return reference && /^[A-Za-z0-9_-]{43}$/.test(reference)
+ ? `/payment-return?reference=${encodeURIComponent(reference)}` : '/';
+}
diff --git a/apps/booking/src/mount.return.test.tsx b/apps/booking/src/mount.return.test.tsx
new file mode 100644
index 00000000..ff4a8e17
--- /dev/null
+++ b/apps/booking/src/mount.return.test.tsx
@@ -0,0 +1,29 @@
+import { act, screen } from '@testing-library/react';
+import { afterEach, expect, it, vi } from 'vitest';
+import { mountBooking } from './mount';
+import { queryClient } from './lib/queryClient';
+
+const status = vi.hoisted(() => vi.fn().mockResolvedValue({ status: 'succeeded' }));
+vi.mock('./api/client', () => ({
+ setBookingKey: vi.fn(),
+ errorMessage: () => 'error',
+ bookingApi: { config: vi.fn().mockResolvedValue({ displayName: 'Example hotel' }), paymentReturnStatus: status },
+}));
+
+afterEach(() => { vi.restoreAllMocks(); queryClient.clear(); window.history.replaceState(null, '', '/'); });
+
+it('mounts the returned widget on a nested host page with no router state or usable storage', async () => {
+ const reference = 'r'.repeat(43);
+ window.history.replaceState(null, '', `/hotel/stays/book?lang=es&haip_payment_return=${reference}#rooms`);
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('Storage unavailable'); });
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage unavailable'); });
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ await act(async () => { mountBooking(host); });
+ await screen.findByText('Payment authorized');
+ expect(status).toHaveBeenCalledWith(reference);
+ expect(window.location.pathname).toBe('/hotel/stays/book');
+ expect(window.location.hash).toBe('#rooms');
+ expect(screen.queryByText('No booking to display.')).toBeNull();
+ host.remove();
+});
diff --git a/apps/booking/src/mount.tsx b/apps/booking/src/mount.tsx
index 4177b75e..c5106fb4 100644
--- a/apps/booking/src/mount.tsx
+++ b/apps/booking/src/mount.tsx
@@ -5,6 +5,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './lib/queryClient';
import { setBookingKey } from './api/client';
import { resolveBookingKey } from './lib/bookingKey';
+import { paymentReturnEntry } from './lib/paymentReturn';
import { resolveTheme, applyTheme } from './lib/theme';
import { ConfigProvider } from './context/ConfigContext';
import { BookingFlowProvider } from './context/BookingFlowContext';
@@ -29,22 +30,6 @@ function BookingWidgetError() {
* Uses MemoryRouter so routing is self-contained and never touches the host
* page's URL/history — safe inside any embedding site.
*/
-function resolveReturnEntry(): string {
- // MemoryRouter ignores the host URL after Redsys returns to the embed page.
- // Bootstrap from window.location so the opaque checkout token survives remount.
- if (typeof window === 'undefined') return '/';
- const params = new URLSearchParams(window.location.search);
- const checkout = params.get('haip_checkout');
- const redsys = params.get('redsys');
- if (checkout && redsys === 'ok') {
- return `/confirmation?haip_checkout=${encodeURIComponent(checkout)}&redsys=ok`;
- }
- if (checkout && redsys === 'ko') {
- return `/payment?haip_checkout=${encodeURIComponent(checkout)}&redsys=ko`;
- }
- return '/';
-}
-
export function mountBooking(el: Element) {
// The key may be carried on the mount element via data-booking-key.
setBookingKey(resolveBookingKey(el));
@@ -71,7 +56,7 @@ export function mountBooking(el: Element) {
),
errorElement: ,
},
- ], { initialEntries: [resolveReturnEntry()] });
+ ], { initialEntries: [paymentReturnEntry(window.location.href)] });
createRoot(el).render(
diff --git a/apps/booking/src/pages/Confirmation.tsx b/apps/booking/src/pages/Confirmation.tsx
index ea9683e0..a2dc3741 100644
--- a/apps/booking/src/pages/Confirmation.tsx
+++ b/apps/booking/src/pages/Confirmation.tsx
@@ -1,120 +1,17 @@
-import { useEffect, useMemo, useState } from 'react';
-import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
-import { useQuery } from '@tanstack/react-query';
-import { bookingApi } from '../api/client';
+import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Button } from '../components/Button';
import { money } from '../lib/format';
-import type { BookResponse, CheckoutStatus } from '../api/types';
+import type { BookResponse } from '../api/types';
interface ConfirmationState {
booking?: BookResponse;
email?: string;
}
-const PENDING_KEY = 'haip.booking.pendingConfirmation';
-
-function readPendingConfirmation(): ConfirmationState | null {
- try {
- const raw = sessionStorage.getItem(PENDING_KEY);
- if (!raw) return null;
- const parsed = JSON.parse(raw) as ConfirmationState;
- if (!parsed?.booking) return null;
- return parsed;
- } catch {
- return null;
- }
-}
-
-function bookingFromCheckout(checkout: CheckoutStatus): BookResponse {
- const authorized =
- checkout.paymentStatus === 'authorized' || checkout.paymentStatus === 'captured';
- return {
- success: true,
- confirmationNumber: checkout.confirmationNumber,
- reservationId: checkout.reservationId,
- status: checkout.reservationStatus,
- currencyCode: checkout.currencyCode,
- grandTotal: checkout.amount,
- deposit: {
- paymentId: checkout.paymentId,
- amount: checkout.amount,
- status:
- checkout.depositStatus ??
- (authorized
- ? 'held'
- : checkout.paymentStatus === 'failed'
- ? 'failed'
- : 'pending_redirect'),
- checkoutToken: checkout.checkoutToken,
- },
- lineItems: [],
- cancellationPolicy: 'See rate plan cancellation policy.',
- };
-}
-
export function Confirmation() {
const navigate = useNavigate();
const { state } = useLocation();
- const [searchParams] = useSearchParams();
- const fromState = (state ?? {}) as ConfirmationState;
- const checkoutToken = searchParams.get('haip_checkout');
- const redsysOk = searchParams.get('redsys') === 'ok';
-
- const checkoutQuery = useQuery({
- queryKey: ['booking-checkout', checkoutToken],
- queryFn: () => bookingApi.getCheckout(checkoutToken!),
- enabled: Boolean(checkoutToken),
- refetchInterval: (query) => {
- const status = query.state.data?.paymentStatus;
- if (!status || status === 'pending') return 2000;
- return false;
- },
- });
-
- const [pending] = useState(() =>
- redsysOk && !fromState.booking ? readPendingConfirmation() : null,
- );
-
- useEffect(() => {
- if (fromState.booking || checkoutQuery.data || pending?.booking) {
- sessionStorage.removeItem(PENDING_KEY);
- }
- }, [fromState.booking, checkoutQuery.data, pending?.booking]);
-
- const resolved = useMemo(() => {
- if (fromState.booking) return fromState;
- if (checkoutQuery.data) {
- return { booking: bookingFromCheckout(checkoutQuery.data) };
- }
- if (pending?.booking) return pending;
- return fromState;
- }, [fromState, checkoutQuery.data, pending]);
-
- const { booking, email } = resolved;
- const paymentStatus = checkoutQuery.data?.paymentStatus;
- const awaitingNotification =
- Boolean(booking) && redsysOk && (!paymentStatus || paymentStatus === 'pending');
- const paymentFailed = paymentStatus === 'failed';
-
- if (checkoutToken && checkoutQuery.isLoading && !booking) {
- return (
-
-
Restoring your booking…
-
- );
- }
-
- if (checkoutToken && checkoutQuery.isError && !booking) {
- return (
-
-
- We could not restore this checkout. If you completed payment, keep any
- confirmation email from the hotel and contact the front desk.
-
-
navigate('/')}>Start a new search
-
- );
- }
+ const { booking, email } = (state ?? {}) as ConfirmationState;
if (!booking) {
return (
@@ -127,31 +24,15 @@ export function Confirmation() {
return (
-
-
- {paymentFailed
- ? 'Payment not completed'
- : awaitingNotification
- ? 'Booking received — confirming payment'
- : 'Booking confirmed'}
+
+
+ Booking confirmed
{booking.confirmationNumber}
- {paymentFailed
- ? 'Your reservation is held pending payment. You can retry from payment or contact the hotel.'
- : awaitingNotification
- ? 'Redsys is notifying the hotel of your payment. Keep this confirmation number.'
- : 'Keep this confirmation number to manage your booking.'}
+ Keep this confirmation number to manage your booking.
@@ -160,10 +41,8 @@ export function Confirmation() {
|
{booking.deposit && (
|
)}
|
diff --git a/apps/booking/src/pages/Payment.redsys.test.tsx b/apps/booking/src/pages/Payment.redsys.test.tsx
index 8493c294..6f332ed2 100644
--- a/apps/booking/src/pages/Payment.redsys.test.tsx
+++ b/apps/booking/src/pages/Payment.redsys.test.tsx
@@ -129,8 +129,9 @@ describe('Payment redsys hosted redirect', () => {
await waitFor(() => expect(bookMock).toHaveBeenCalledTimes(1));
const body = bookMock.mock.calls[0][0];
expect(body.paymentToken).toBe('redsys_redirect');
- expect(body.redirectUrlOk).toMatch(/redsys=ok/);
- expect(body.redirectUrlKo).toMatch(/redsys=ko/);
+ expect(body.returnUrl).toBe(window.location.href);
+ expect(body.redirectUrlOk).toBeUndefined();
+ expect(body.redirectUrlKo).toBeUndefined();
await waitFor(() => expect(submitRedirect).toHaveBeenCalledWith(nextAction));
expect(navigate).not.toHaveBeenCalledWith(
diff --git a/apps/booking/src/pages/Payment.tsx b/apps/booking/src/pages/Payment.tsx
index 4daf8b37..2d7c4f08 100644
--- a/apps/booking/src/pages/Payment.tsx
+++ b/apps/booking/src/pages/Payment.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo } from 'react';
-import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { useMutation } from '@tanstack/react-query';
@@ -13,29 +13,12 @@ import { money } from '../lib/format';
import { submitRedirectNextAction } from '../lib/submit-redirect-next-action';
import type { BookRequest, BookResponse } from '../api/types';
-
-function buildRedsysReturnUrls(): { redirectUrlOk: string; redirectUrlKo: string } {
- // Return to the current host page (embed-safe). Server appends haip_checkout.
- const base = new URL(window.location.href);
- base.searchParams.delete('redsys');
- base.searchParams.delete('haip_checkout');
- const ok = new URL(base);
- ok.searchParams.set('redsys', 'ok');
- const ko = new URL(base);
- ko.searchParams.set('redsys', 'ko');
- return { redirectUrlOk: ok.toString(), redirectUrlKo: ko.toString() };
-}
-
-
type BookPaymentInput = PaymentResult & {
- redirectUrlOk?: string;
- redirectUrlKo?: string;
+ returnUrl?: string;
};
export function Payment() {
const navigate = useNavigate();
- const [searchParams] = useSearchParams();
- const redsysFailed = searchParams.get('redsys') === 'ko';
const { config } = useConfig();
const { criteria, roomType, rate, quote, guest, serviceIds } = useBookingFlow();
@@ -63,22 +46,16 @@ export function Payment() {
cardLastFour: payment?.cardLastFour,
cardBrand: payment?.cardBrand,
serviceIds: serviceIds.length ? serviceIds : undefined,
- redirectUrlOk: payment?.redirectUrlOk,
- redirectUrlKo: payment?.redirectUrlKo,
+ returnUrl: payment?.returnUrl,
};
return bookingApi.book(body);
},
onSuccess: (res: BookResponse) => {
const nextAction = res.deposit?.nextAction;
if (nextAction?.type === 'redirect') {
- sessionStorage.setItem(
- 'haip.booking.pendingConfirmation',
- JSON.stringify({ booking: res, email: guest!.email }),
- );
submitRedirectNextAction(nextAction);
return;
}
- sessionStorage.removeItem('haip.booking.pendingConfirmation');
navigate('/confirmation', { state: { booking: res, email: guest!.email } });
},
});
@@ -101,7 +78,7 @@ export function Payment() {
const payRedsys = () =>
bookMutation.mutate({
paymentToken: 'redsys_redirect',
- ...buildRedsysReturnUrls(),
+ returnUrl: window.location.href,
});
return (
@@ -113,11 +90,6 @@ export function Payment() {
- {redsysFailed && (
-
- Payment was cancelled or declined. You can try again to complete your booking.
-
- )}
{bookMutation.isError && (
{errorMessage(bookMutation.error)}
)}
diff --git a/apps/booking/src/pages/PaymentReturn.test.tsx b/apps/booking/src/pages/PaymentReturn.test.tsx
new file mode 100644
index 00000000..05fb1540
--- /dev/null
+++ b/apps/booking/src/pages/PaymentReturn.test.tsx
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter } from 'react-router-dom';
+import { PaymentReturn } from './PaymentReturn';
+import { paymentReturnEntry } from '../lib/paymentReturn';
+
+const status = vi.hoisted(() => vi.fn());
+vi.mock('../api/client', () => ({ bookingApi: { paymentReturnStatus: status } }));
+afterEach(() => { cleanup(); vi.resetAllMocks(); });
+const reference = 'a'.repeat(43);
+
+function show() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
);
+}
+
+describe('hosted payment return without memory or storage', () => {
+ it('bootstraps from the embedding URL and ignores browser success flags', () => {
+ expect(paymentReturnEntry(`https://hotel.example/stays/book?haip_payment_return=${reference}`)).toBe(`/payment-return?reference=${reference}`);
+ expect(paymentReturnEntry('https://hotel.example/stays/book?redsys=ok')).toBe('/');
+ expect(paymentReturnEntry('https://hotel.example/stays/book?haip_payment_return=bad')).toBe('/');
+ });
+ it('shows processing before the callback and refreshes to server success', async () => {
+ status.mockResolvedValueOnce({ status: 'processing' }).mockResolvedValue({ status: 'succeeded' });
+ show();
+ await screen.findByText('Confirming your payment');
+ expect(screen.queryByText('Payment authorized')).toBeNull();
+ expect(status).toHaveBeenCalledWith(reference);
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Check payment status' })).not.toBeDisabled());
+ fireEvent.click(screen.getByRole('button', { name: 'Check payment status' }));
+ await screen.findByText('Payment authorized');
+ });
+ it('shows success when the callback arrived before the return', async () => {
+ status.mockResolvedValue({ status: 'succeeded' });
+ show();
+ await screen.findByText('Payment authorized');
+ expect(screen.queryByText('Booking confirmed')).toBeNull();
+ });
+ it('polls a processing payment automatically until authorization arrives', async () => {
+ status.mockResolvedValueOnce({ status: 'processing' }).mockResolvedValue({ status: 'succeeded' });
+ show();
+ await screen.findByText('Payment authorized', {}, { timeout: 4500 });
+ expect(status).toHaveBeenCalledTimes(2);
+ });
+ it.each(['failed', 'cancelled'])('shows server %s and safe retry guidance', async (value) => {
+ status.mockResolvedValue({ status: value });
+ show();
+ await screen.findByText(value === 'failed' ? 'Payment was not completed' : 'Payment was cancelled');
+ expect(screen.getByText(/contact the hotel before trying again/i)).toBeTruthy();
+ });
+ it('offers status retry without rebooking when lookup fails', async () => {
+ status.mockRejectedValueOnce(new Error('offline')).mockResolvedValue({ status: 'processing' });
+ show();
+ await screen.findByText('We could not check your payment');
+ fireEvent.click(screen.getByRole('button', { name: 'Check payment status' }));
+ await waitFor(() => expect(status).toHaveBeenCalledTimes(2));
+ await screen.findByText('Confirming your payment');
+ });
+});
diff --git a/apps/booking/src/pages/PaymentReturn.tsx b/apps/booking/src/pages/PaymentReturn.tsx
new file mode 100644
index 00000000..0e5ebb2a
--- /dev/null
+++ b/apps/booking/src/pages/PaymentReturn.tsx
@@ -0,0 +1,40 @@
+import { useQuery } from '@tanstack/react-query';
+import { useSearchParams } from 'react-router-dom';
+import { bookingApi } from '../api/client';
+import { Button } from '../components/Button';
+
+/** Payment state comes exclusively from the API, including after OK/KO navigation. */
+export function PaymentReturn() {
+ const [params] = useSearchParams();
+ const reference = params.get('reference') ?? '';
+ const payment = useQuery({
+ queryKey: ['payment-return', reference],
+ queryFn: () => bookingApi.paymentReturnStatus(reference),
+ retry: false,
+ refetchInterval: (query) => query.state.data?.status === 'processing' ? 3000 : false,
+ });
+ const status = payment.data?.status;
+ const title = payment.isError ? 'We could not check your payment'
+ : status === 'succeeded' ? 'Payment authorized'
+ : status === 'failed' ? 'Payment was not completed'
+ : status === 'cancelled' ? 'Payment was cancelled'
+ : status === 'unavailable' ? 'Contact the hotel about your payment'
+ : 'Confirming your payment';
+ return (
+
+
{title}
+
+ {status === 'succeeded' && !payment.isError
+ ? 'The hotel has received your payment authorization. Contact the hotel for your booking details.'
+ : status === 'failed' || status === 'cancelled'
+ ? 'Please contact the hotel before trying again to avoid a duplicate booking or payment.'
+ : 'Your payment status is being checked with the hotel. If you cancelled or your payment did not complete, contact the hotel before trying again to avoid a duplicate booking or payment.'}
+
+ {status !== 'succeeded' && (
+
void payment.refetch()} disabled={payment.isFetching}>
+ Check payment status
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/pages/Folios.test.tsx b/apps/dashboard/src/pages/Folios.test.tsx
index abd8b290..8431813d 100644
--- a/apps/dashboard/src/pages/Folios.test.tsx
+++ b/apps/dashboard/src/pages/Folios.test.tsx
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
@@ -58,12 +58,12 @@ function mockGet(overrides: Record
= {}) {
});
}
-function renderDetail() {
+function renderDetail(entry = '/folio-1') {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
});
return render(
-
+
@@ -143,12 +143,43 @@ describe('Folios — split folios', () => {
});
describe('Folios — payment corrections', () => {
+ afterEach(() => { vi.restoreAllMocks(); });
beforeEach(() => {
vi.clearAllMocks();
mockGet();
(api.post as any).mockResolvedValue({ data: { op: 'refund' } });
});
+ it.each(['ok', 'ko'])('recovers a hosted %s return without treating the browser flag as authorization', async (outcome) => {
+ mockGet({ '/v1/payments': [{ ...PAYMENT, status: 'pending' }], '/v1/payments/client-config': { provider: 'redsys', clientMode: 'redsys' } });
+ renderDetail(`/folio-1?redsys=${outcome}`);
+ expect(await screen.findByText(outcome === 'ok'
+ ? 'Returned from Redsys. Payment status updates when the bank notification arrives.'
+ : 'Redsys payment was cancelled or declined.')).toBeInTheDocument();
+ expect(await screen.findByText('F-0001')).toBeInTheDocument();
+ expect(api.post).not.toHaveBeenCalled();
+ expect(screen.queryByText('Capture')).not.toBeInTheDocument();
+ expect(screen.queryByText('Void')).not.toBeInTheDocument();
+ });
+
+ it('submits the hosted authorization form returned by the property-scoped API', async () => {
+ mockGet({ '/v1/payments/client-config': { provider: 'redsys', clientMode: 'redsys' } });
+ const nextAction = { type: 'redirect', method: 'POST', url: 'https://bank.example/checkout', formFields: { Ds_MerchantParameters: 'test-parameters', Ds_Signature: 'test-signature' } };
+ vi.mocked(api.post).mockResolvedValue({ data: { status: 'pending', nextAction } });
+ const submit = vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => undefined);
+ renderDetail();
+ await userEvent.click(await screen.findByRole('button', { name: /Authorize Card/i }));
+ await userEvent.type(screen.getByRole('spinbutton'), '100');
+ await userEvent.click(screen.getByRole('button', { name: 'Authorize with Redsys' }));
+ await waitFor(() => expect(submit).toHaveBeenCalledOnce());
+ const submitted = submit.mock.contexts[0] as HTMLFormElement;
+ expect(api.post).toHaveBeenCalledWith('/v1/payments/authorize', expect.objectContaining({ propertyId: 'prop-1', folioId: 'folio-1', amount: '100.00', currencyCode: 'USD', gatewayProvider: 'redsys' }));
+ expect(submitted.method).toBe('post');
+ expect(submitted.action).toBe(nextAction.url);
+ expect(new FormData(submitted).get('Ds_MerchantParameters')).toBe('test-parameters');
+ submitted.remove();
+ });
+
it('lets the API pick the legal op when none is chosen', async () => {
renderDetail();
await userEvent.click(await screen.findByText('Correct'));
diff --git a/apps/dashboard/src/pages/Integrations.test.tsx b/apps/dashboard/src/pages/Integrations.test.tsx
new file mode 100644
index 00000000..d3a7729d
--- /dev/null
+++ b/apps/dashboard/src/pages/Integrations.test.tsx
@@ -0,0 +1,51 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ToastProvider } from '../components/ui/Toast';
+import Integrations from './Integrations';
+
+vi.mock('../context/PropertyContext', () => ({ useProperty: () => ({ propertyId: 'prop-1' }) }));
+vi.mock('../lib/api', () => ({ api: { get: vi.fn(), put: vi.fn() } }));
+import { api } from '../lib/api';
+
+const config = { merchantCode: '999008881', terminal: '001', environment: 'test', secretKeyMasked: '••••••••' };
+function renderIntegrations() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+ return render( );
+}
+
+describe('Redsys integration settings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(api.get).mockResolvedValue({ data: [{ slug: 'redsys', name: 'Redsys', category: 'Payments', status: 'shipped', description: 'TPV', enabled: true, connectionId: 'conn-1', config }] });
+ vi.mocked(api.put).mockResolvedValue({ data: {} });
+ });
+
+ it('keeps a saved credential blank and omits it when saving other settings', async () => {
+ renderIntegrations();
+ expect(await screen.findByLabelText('Secret key')).toHaveValue('');
+ expect(screen.getByText('Leave blank to keep the existing secret.')).toBeInTheDocument();
+ await userEvent.selectOptions(screen.getByLabelText('Environment'), 'live');
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+ await waitFor(() => expect(api.put).toHaveBeenCalledWith('/v1/admin/integrations/redsys', {
+ enabled: true, config: { merchantCode: '999008881', terminal: '001', environment: 'live' },
+ }, { params: { propertyId: 'prop-1' } }));
+ });
+
+ it('submits a replacement secret then clears the input', async () => {
+ renderIntegrations();
+ await userEvent.type(await screen.findByLabelText('Secret key'), 'replacement-test-key');
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+ await waitFor(() => expect(api.put).toHaveBeenCalledWith('/v1/admin/integrations/redsys', {
+ enabled: true, config: { merchantCode: '999008881', terminal: '001', environment: 'test', secretKey: 'replacement-test-key' },
+ }, { params: { propertyId: 'prop-1' } }));
+ await waitFor(() => expect(screen.getByLabelText('Secret key')).toHaveValue(''));
+ });
+
+ it('toggles enablement using the masked config and the selected property', async () => {
+ renderIntegrations();
+ await userEvent.click(await screen.findByRole('button', { name: 'Disable' }));
+ await waitFor(() => expect(api.put).toHaveBeenCalledWith('/v1/admin/integrations/redsys', { enabled: false, config }, { params: { propertyId: 'prop-1' } }));
+ });
+});
diff --git a/docs/integrations/payments-redsys.md b/docs/integrations/payments-redsys.md
index 5e7ffec4..4a4143f7 100644
--- a/docs/integrations/payments-redsys.md
+++ b/docs/integrations/payments-redsys.md
@@ -15,7 +15,7 @@ Stripe remains the default when `PAYMENT_GATEWAY` is unset and `STRIPE_MODE` is
| `REDSYS_ENV` | `test` (default) \| `live` | Chooses `sis-t` vs `sis` endpoints |
| `PUBLIC_API_BASE_URL` | `https://…` | Used to build MerchantURL `…/api/v1/webhooks/redsys` |
-When credentials are missing, the adapter runs in **console mode** (logged mock success, no HTTP) — same pattern as Mollie/Adyen.
+Missing credentials fail closed in every environment. Direct booking checks the property's credentials and currency precision before creating provisional records. For deliberate demos select `PAYMENT_GATEWAY=mock` subject to the existing production mock guard.
## Per-property credentials
@@ -32,16 +32,25 @@ Each Spanish hotel typically has its own FUC. Store credentials on the property
Dashboard → Integrations → Redsys exposes this form. Property config overrides process env at authorize/capture/void/refund time.
-## Flows
+Signing keys are stored as AES-256-GCM credential blobs using HAIP's existing protected-credential key ring (`MIGRATION_CREDENTIAL_ENCRYPTION_KEY`, `MIGRATION_CREDENTIAL_ENCRYPTION_KEY_ID`, and optional `MIGRATION_CREDENTIAL_ENCRYPTION_KEYS`). Provision the existing 32-byte encryption key through deployment secrets before saving credentials; never place it in integration config. Retain old key IDs in the rotation map until their stored blobs have been rotated. Missing keys or invalid ciphertext fail closed. Only the payment credential resolver decrypts a signing key.
-### Authorize (deposit / folio hold)
+Input accepts `secretKey`, `secret_key`, or `clave`; all are canonicalized into `secretKeyEncrypted` at rest. Public list/get/save responses strip every spelling and the encrypted blob, returning only a fixed `secretKeyMasked` configured indicator. A blank or omitted secret preserves the existing credential; callers cannot submit ciphertext.
+
+### Existing installations
+
+Run the normal `pnpm db:migrate` command (including payment migrations **0024, 0025, and 0026**), build the API, then run the idempotent application data migration with the production database URL and existing credential key ring supplied securely in the environment:
+
+```bash
+node apps/api/dist/scripts/protect-redsys-credentials.js
+```
-> **Lifecycle (hosted redirect):** browser URLOK/URLKO is navigation only.
-> The signed MerchantURL notification is the authority. HAIP records the
-> deposit ledger entry and runs booking-engine auto-confirm only after that
-> verified success (idempotent finalizer). Return URLs carry an opaque
-> `haip_checkout` token so the MemoryRouter booking widget can restore state.
+Run this before enabling payment traffic; it protects existing canonical and alias values for enabled and disabled integrations. It uses tenant-scoped row locks, commits each protected config together with a redacted audit record, preserves other settings, and exits nonzero without printing credentials on failure. Rerun after correcting configuration. Existing backups may still contain historical plaintext and need the deployment's usual protected retention handling. No new schema is required because the blob uses the existing JSONB config. Do not roll back to a build that only understands plaintext signing keys.
+For read compatibility, the payment resolver also performs the same scoped migration before reading a legacy credential. Public configuration reads mask legacy values even without an encryption key; they do not decrypt them. This compatibility path is not a replacement for the deployment migration, since inactive rows must also be protected.
+
+## Flows
+
+### Authorize (deposit / folio hold)
1. Client calls `POST /api/v1/payments/authorize` with `gatewayProvider: "redsys"`, `gatewayPaymentToken: "redsys_redirect"`, and `redirectUrlOk` / `redirectUrlKo`.
2. HAIP creates a **pending** payment, signs `Ds_MerchantParameters` (HMAC_SHA512_V2), and returns `nextAction` (POST form fields + Redsys `realizarPago` URL).
@@ -63,6 +72,10 @@ Server-side REST `trataPeticionREST`:
`transactionId` stored on the payment row is the Redsys `Ds_Order` (4–12 chars).
+All requests use exact currency minor units from the existing ISO-4217 ledger currency table: EUR/USD/GBP/CHF have two decimals, JPY has none. Unsupported currencies and fractional minor units are rejected. Void includes the original authorized amount and currency from the persisted payment. REST success requires `HMAC_SHA512_V2`, a valid signature, matching order/merchant/terminal/amount/currency/type, and the operation's exact success code (`0900` for capture/refund, `0400` for void).
+
+CI and release run the real PostgreSQL callback and protected-credential suites against their disposable `haip_test` service via `REDSYS_TEST_DATABASE_URL`. Local runs remain opt-in; point that variable only at a disposable migrated test database. Fixtures use random tenant IDs and remove only their own rows.
+
## Client mode
`paymentMethodClientMode` becomes `redsys` when `PAYMENT_GATEWAY=redsys`. The booking widget and folio authorize UI use hosted redirect instead of Stripe Elements.
diff --git a/docs/test-stats.json b/docs/test-stats.json
index 4f0636d2..5e182da4 100644
--- a/docs/test-stats.json
+++ b/docs/test-stats.json
@@ -1,7 +1,7 @@
{
- "tests": 2261,
- "files": 273,
+ "tests": 2392,
+ "files": 280,
"scope": "all workspace packages with a test script",
"semantics": "passed test cases and files containing at least one passed test; skipped test cases and skipped-only files are excluded",
- "updatedAt": "2026-09-09T23:12:54.422Z"
-}
\ No newline at end of file
+ "updatedAt": "2026-09-09T23:52:25.972Z"
+}
diff --git a/integrations/demos/redsys/README.md b/integrations/demos/redsys/README.md
index c841a013..6ade7719 100644
--- a/integrations/demos/redsys/README.md
+++ b/integrations/demos/redsys/README.md
@@ -11,7 +11,7 @@ One command (API must be running):
What it does:
1. Turns **ON** the property Integrations catalog toggle for `redsys`.
2. Notes process env (`PAYMENT_GATEWAY=redsys`) — restart the API after changing it.
-3. Works in **console** mode when FUC/secret are missing (no live Spanish bank account required for the demo path).
+3. Requires configured sandbox or live merchant credentials for Redsys. For a deliberate offline demo, select `PAYMENT_GATEWAY=mock` and restart the API; unconfigured Redsys operations fail closed.
## Env
diff --git a/integrations/demos/redsys/demo.env.example b/integrations/demos/redsys/demo.env.example
index 923be6f9..b7573337 100644
--- a/integrations/demos/redsys/demo.env.example
+++ b/integrations/demos/redsys/demo.env.example
@@ -7,7 +7,8 @@ PAYMENT_GATEWAY=redsys
# Public API base used as Redsys MerchantURL (must be reachable by Redsys).
# PUBLIC_API_BASE_URL=https://your-tunnel.example.com
-# --- sandbox / live (optional) ---
+# --- sandbox / live (required for PAYMENT_GATEWAY=redsys) ---
+# For a deliberate offline demo use PAYMENT_GATEWAY=mock instead.
# Official Redsys test FUC (replace with your bank-issued credentials for live):
# REDSYS_MERCHANT_CODE=999008881
# REDSYS_TERMINAL=001
diff --git a/integrations/demos/redsys/demo.sh b/integrations/demos/redsys/demo.sh
index 5741dcdf..72a41d43 100755
--- a/integrations/demos/redsys/demo.sh
+++ b/integrations/demos/redsys/demo.sh
@@ -11,7 +11,7 @@ echo "API: $HAIP_URL property: $PROPERTY_ID"
require_api
enable_registry 'redsys'
echo "→ Payment demos use process env (restart API after setting PAYMENT_GATEWAY)."
-echo " Demo mode works with missing vendor keys (console gateway)."
+echo " For a deliberate offline demo select PAYMENT_GATEWAY=mock; unconfigured Redsys fails closed."
echo " For live/sandbox redirect: set REDSYS_MERCHANT_CODE / REDSYS_TERMINAL / REDSYS_SECRET_KEY / REDSYS_ENV=test"
echo " and PUBLIC_API_BASE_URL to a URL Redsys can reach for MerchantURL notifications."
diff --git a/packages/booking-requests/src/index.ts b/packages/booking-requests/src/index.ts
index 571c5dbe..2b13109d 100644
--- a/packages/booking-requests/src/index.ts
+++ b/packages/booking-requests/src/index.ts
@@ -26,6 +26,7 @@ export type {
PublicBookingEngineConfig,
} from './module/ports.js';
export { isBookingRequestsEnabled } from './enabled.js';
+export { assertLedgerCurrencySupported } from './domain/booking-request-money.js';
/**
* Domain services + controllers, exported for apps/api's kept regression/e2e/
* authorization specs (see `apps/api/src/modules/booking-request/*.spec.ts`),
diff --git a/packages/database/src/migrations/0024_payment_authorization_finalization.sql b/packages/database/src/migrations/0024_payment_authorization_finalization.sql
new file mode 100644
index 00000000..f54b906a
--- /dev/null
+++ b/packages/database/src/migrations/0024_payment_authorization_finalization.sql
@@ -0,0 +1,4 @@
+-- Preserve server-side booking intent until asynchronous authorization completes.
+-- Existing payments remain NULL; their purpose cannot safely be inferred.
+ALTER TABLE payments
+ ADD COLUMN IF NOT EXISTS authorization_finalization jsonb;
diff --git a/packages/database/src/migrations/0025_payment_booking_return_reference.sql b/packages/database/src/migrations/0025_payment_booking_return_reference.sql
new file mode 100644
index 00000000..a6c33651
--- /dev/null
+++ b/packages/database/src/migrations/0025_payment_booking_return_reference.sql
@@ -0,0 +1,5 @@
+-- Guest return capabilities expose only status; raw references are never stored.
+ALTER TABLE payments
+ ADD COLUMN IF NOT EXISTS booking_return_reference_hash varchar(64);
+CREATE UNIQUE INDEX IF NOT EXISTS payments_booking_return_reference_unique
+ ON payments (booking_return_reference_hash);
diff --git a/packages/database/src/migrations/0026_payment_booking_return_destination.sql b/packages/database/src/migrations/0026_payment_booking_return_destination.sql
new file mode 100644
index 00000000..1678462e
--- /dev/null
+++ b/packages/database/src/migrations/0026_payment_booking_return_destination.sql
@@ -0,0 +1,4 @@
+-- Persist the prevalidated hotel page so hosted return URLs can stay compact.
+-- The raw return capability is appended only after lookup, never stored here.
+ALTER TABLE payments
+ ADD COLUMN IF NOT EXISTS booking_return_destination text;
diff --git a/packages/database/src/schema/folio.ts b/packages/database/src/schema/folio.ts
index 704458ea..4a8dac39 100644
--- a/packages/database/src/schema/folio.ts
+++ b/packages/database/src/schema/folio.ts
@@ -1,4 +1,4 @@
-import { foreignKey, pgTable, uuid, varchar, text, boolean, timestamp, numeric, pgEnum, uniqueIndex } from 'drizzle-orm/pg-core';
+import { foreignKey, pgTable, uuid, varchar, text, boolean, timestamp, numeric, pgEnum, uniqueIndex, jsonb } from 'drizzle-orm/pg-core';
import { properties } from './property.js';
import { reservations, bookings } from './reservation.js';
import { guests } from './guest.js';
@@ -185,6 +185,19 @@ export const payments = pgTable('payments', {
// NEVER store raw card data. Stripe/Adyen tokenization only.
gatewayProvider: varchar('gateway_provider', { length: 20 }), // "stripe", "adyen"
gatewayTransactionId: varchar('gateway_transaction_id', { length: 255 }),
+ // Hash of the limited guest payment-status capability; never the raw reference.
+ bookingReturnReferenceHash: varchar('booking_return_reference_hash', { length: 64 }),
+ // Prevalidated embedding page for the browser relay; excludes the raw capability.
+ bookingReturnDestination: text('booking_return_destination'),
+ // Server-owned snapshot for authorization that completes asynchronously.
+ // Nullable for synchronous/legacy payments; never populated from public DTOs.
+ authorizationFinalization: jsonb('authorization_finalization').$type<{
+ deposit: {
+ reservationId: string;
+ isRefundable: boolean;
+ autoConfirm: boolean;
+ };
+ }>(),
gatewayPaymentToken: varchar('gateway_payment_token', { length: 255 }), // Tokenized card reference
cardLastFour: varchar('card_last_four', { length: 4 }),
cardBrand: varchar('card_brand', { length: 20 }), // "visa", "mastercard", "amex"
@@ -215,4 +228,6 @@ export const payments = pgTable('payments', {
// core's schema only needs the plain `bookingRequestId`/`idempotencyKey` columns.
propertyIdempotencyKeyUnique: uniqueIndex('payments_property_idempotency_key_unique')
.on(table.propertyId, table.idempotencyKey),
+ bookingReturnReferenceUnique: uniqueIndex('payments_booking_return_reference_unique')
+ .on(table.bookingReturnReferenceHash),
}));
diff --git a/packages/shared/src/payment-gateway.interface.ts b/packages/shared/src/payment-gateway.interface.ts
index 3ba8f56c..873b3b90 100644
--- a/packages/shared/src/payment-gateway.interface.ts
+++ b/packages/shared/src/payment-gateway.interface.ts
@@ -25,6 +25,8 @@ export interface PaymentGatewayCallOptions {
idempotencyKey?: string;
/** Required for amount-bearing capture/refund calls outside scale-two currencies. */
currencyCode?: string;
+ /** Original authorized amount from the server's payment row (required by Redsys void). */
+ authorizedAmount?: number;
/** Property that owns the charge — used by per-merchant PSPs (Redsys FUC). */
propertyId?: string;
/** Return / notification URLs for hosted redirect authorize. */
@@ -32,8 +34,6 @@ export interface PaymentGatewayCallOptions {
merchantUrl: string;
urlOk: string;
urlKo: string;
- /** Pre-assigned Redsys order id so return URLs can embed the same opaque token. */
- orderId?: string;
};
/** Per-property merchant credentials (overrides process env when set). */
merchantCredentials?: {