diff --git a/README.md b/README.md
index dc3f137..2d1793c 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 (1509 tests across 214 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | +| Testing | Vitest (1514 tests across 214 test files) | 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 | @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# All tests (1509 tests across 214 test files) +# All tests (1514 tests across 214 test files) # API tests only pnpm --filter @telivityhaip/api test @@ -1188,7 +1188,7 @@ HAIP is built in public and contributions are welcome. pnpm install # Install dependencies pnpm build # Build all workspace packages pnpm dev # Start API in dev mode (hot reload) -pnpm test # Run all tests (1509 tests, 214 files) +pnpm test # Run all tests (1514 tests, 214 files) pnpm lint # ESLint ``` diff --git a/apps/api/src/modules/payment/dto/create-payment.dto.ts b/apps/api/src/modules/payment/dto/create-payment.dto.ts index 8ed3671..5911e4d 100644 --- a/apps/api/src/modules/payment/dto/create-payment.dto.ts +++ b/apps/api/src/modules/payment/dto/create-payment.dto.ts @@ -4,6 +4,7 @@ import { IsOptional, IsUUID, IsEnum, + IsDateString, MaxLength, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -68,6 +69,20 @@ export class CreatePaymentDto { @MaxLength(255) gatewayPaymentToken?: string; + @ApiPropertyOptional({ + example: 'sqpmt_01H...', + description: + "The gateway's own id for a payment taken OUT OF BAND — a link the guest paid, " + + 'a terminal, or a charge made in the provider dashboard. The column already ' + + 'exists but only the authorize/capture path could populate it, so a payment ' + + 'recorded after the fact carried no reference back to the provider and could ' + + 'not be reconciled against a settlement report.', + }) + @IsOptional() + @IsString() + @MaxLength(255) + gatewayTransactionId?: string; + @ApiPropertyOptional({ example: '4242', description: 'Last 4 digits only' }) @IsOptional() @IsString() @@ -84,4 +99,16 @@ export class CreatePaymentDto { @IsOptional() @IsString() notes?: string; + + @ApiPropertyOptional({ + example: '2026-08-03T00:37:33Z', + description: + 'When the money actually moved. Omit for payments taken now — the server stamps ' + + 'the current time. Supply it only when recording a payment that ALREADY happened ' + + 'elsewhere, such as a historical import or an out-of-band gateway receipt. Must ' + + 'not be in the future.', + }) + @IsOptional() + @IsDateString() + processedAt?: string; } diff --git a/apps/api/src/modules/payment/payment.service.spec.ts b/apps/api/src/modules/payment/payment.service.spec.ts index 1b69c25..e5b6e23 100644 --- a/apps/api/src/modules/payment/payment.service.spec.ts +++ b/apps/api/src/modules/payment/payment.service.spec.ts @@ -166,6 +166,101 @@ describe('PaymentService', () => { ).rejects.toThrow(BadRequestException); }); + it('should record an out-of-band gateway payment with its transaction id', async () => { + // The guest paid a payment link; the money is already at the provider. + // There is nothing to authorize, and without the transaction id the + // payment can never be matched to a settlement report. + await service.recordPayment({ + folioId: 'folio-001', + propertyId: 'prop-001', + method: 'credit_card', + amount: '150.00', + currencyCode: 'USD', + gatewayProvider: 'square', + gatewayTransactionId: 'sqpmt_abc123', + }); + + // insert() returns one shared chain object in this mock, so the values() + // call it received is what actually reached the database. + const chain = (mockDb.insert as any).mock.results[0].value; + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayProvider: 'square', + gatewayTransactionId: 'sqpmt_abc123', + status: 'captured', + }), + ); + }); + + it('should record a historical payment at the date the money actually moved', async () => { + // A migration or an out-of-band receipt records money that moved in the PAST. + // Stamping it with the import time makes the ledger disagree with the + // settlement report it exists to be reconciled against. + await service.recordPayment({ + folioId: 'folio-001', + propertyId: 'prop-001', + method: 'credit_card', + amount: '150.00', + currencyCode: 'USD', + gatewayProvider: 'square', + gatewayTransactionId: 'sqpmt_abc123', + processedAt: '2026-08-03T00:37:33.000Z', + }); + + const chain = (mockDb.insert as any).mock.results[0].value; + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + processedAt: new Date('2026-08-03T00:37:33.000Z'), + }), + ); + }); + + it('should still stamp now when processedAt is omitted', async () => { + // The negative control for the test above: if the field were ignored + // entirely, that test could pass while this one silently proved nothing. + const before = Date.now(); + await service.recordPayment({ + folioId: 'folio-001', + propertyId: 'prop-001', + method: 'cash', + amount: '150.00', + currencyCode: 'USD', + }); + + const chain = (mockDb.insert as any).mock.results[0].value; + const written = chain.values.mock.calls[0][0].processedAt as Date; + expect(written.getTime()).toBeGreaterThanOrEqual(before); + expect(written.getTime()).toBeLessThanOrEqual(Date.now()); + }); + + it('should reject a processedAt in the future', async () => { + await expect( + service.recordPayment({ + folioId: 'folio-001', + propertyId: 'prop-001', + method: 'cash', + amount: '150.00', + currencyCode: 'USD', + processedAt: new Date(Date.now() + 86_400_000).toISOString(), + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should reject a card payment naming a gateway with no transaction id', async () => { + // No token and no receipt: this is an attempt to take a card payment + // through the settle path, which is what the authorize flow is for. + await expect( + service.recordPayment({ + folioId: 'folio-001', + propertyId: 'prop-001', + method: 'credit_card', + amount: '150.00', + currencyCode: 'USD', + gatewayProvider: 'square', + }), + ).rejects.toThrow(BadRequestException); + }); + it('should reject vcc on the record path', async () => { await expect( service.recordPayment({ diff --git a/apps/api/src/modules/payment/payment.service.ts b/apps/api/src/modules/payment/payment.service.ts index f5713b0..7579344 100644 --- a/apps/api/src/modules/payment/payment.service.ts +++ b/apps/api/src/modules/payment/payment.service.ts @@ -39,12 +39,26 @@ export class PaymentService { `VCC payments must use the authorize flow. Use POST /payments/authorize instead.`, ); } + // A TOKEN is a chargeable instrument: presenting one here is an attempt to + // take money through the settle path, and must still go via authorize. + // A TRANSACTION ID is the opposite — evidence that a charge already + // happened somewhere else (a payment link the guest paid, a terminal, the + // provider's own dashboard). Recording that after the fact is the only way + // an out-of-band payment can ever be reconciled against a settlement + // report, and refusing it forced those payments to be logged with no + // reference to the provider at all. + if (CARD_METHODS.includes(dto.method) && dto.gatewayPaymentToken) { + throw new BadRequestException( + `Card payments with a gateway token must use the authorize flow. Use POST /payments/authorize instead.`, + ); + } if ( CARD_METHODS.includes(dto.method) && - (dto.gatewayPaymentToken || dto.gatewayProvider) + dto.gatewayProvider && + !dto.gatewayTransactionId ) { throw new BadRequestException( - `Card payments with a gateway token must use the authorize flow. Use POST /payments/authorize instead.`, + `A card payment naming a gateway must either carry gatewayTransactionId (a payment already taken there) or use POST /payments/authorize to take one.`, ); } @@ -53,12 +67,23 @@ export class PaymentService { throw new BadRequestException('Cannot record payment on a folio that is not open'); } + // processedAt is WHEN THE MONEY MOVED, which is not always now. Historical imports + // and out-of-band gateway receipts record payments that already happened, and + // stamping those with the import time makes the ledger disagree with the + // settlement report it is supposed to reconcile against. Note the spread below + // would otherwise carry dto.processedAt through as a STRING and then be silently + // overwritten by the hardcoded new Date() — resolve it explicitly instead. + const processedAt = dto.processedAt ? new Date(dto.processedAt) : new Date(); + if (processedAt.getTime() > Date.now()) { + throw new BadRequestException('processedAt cannot be in the future'); + } + const [payment] = await this.db .insert(payments) .values({ ...dto, status: 'captured', - processedAt: new Date(), + processedAt, }) .returning(); diff --git a/docs/test-stats.json b/docs/test-stats.json index 05b277d..cd4f311 100644 --- a/docs/test-stats.json +++ b/docs/test-stats.json @@ -1,5 +1,5 @@ { - "tests": 1509, + "tests": 1514, "files": 214, - "updatedAt": "2026-08-13T05:06:18.343Z" + "updatedAt": "2026-08-16T00:15:37.698Z" }