From 15d352eced6a603cf7d7284684dc76764118af23 Mon Sep 17 00:00:00 2001 From: Charles Pizzato <311327716+modernitconsultants@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:21:30 +1000 Subject: [PATCH 1/2] feat(payments): record an out-of-band gateway payment with its reference and date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciling money means matching three things by id: what was due, what the ledger holds, and what the provider actually took. The payments table already has gateway_transaction_id, but CreatePaymentDto did not accept it and only the authorize/capture path could populate it. So a payment taken OUTSIDE the PMS — a payment link the guest paid, a terminal, a charge made in the provider's own dashboard — could only be recorded with no reference back to the provider at all, and nothing to reconcile against a settlement report. The settle-path guard is refined rather than relaxed, because it was conflating two opposites. A TOKEN is a chargeable instrument: presenting one here is an attempt to take money through the settle path and still goes via authorize. A TRANSACTION ID is evidence a charge already happened elsewhere. So a card method carrying a token is still refused, and a card method naming a gateway must now carry either a transaction id or use authorize — naming a provider with neither is what the original guard was really aimed at, and stays refused. processedAt is the same argument for time. A historical import stamped with the import date makes the ledger disagree with the settlement report it exists to be reconciled against. Optional, refused if in the future, and resolved explicitly rather than left to the object spread — where it would have been carried through as a string and then silently overwritten by the hardcoded new Date(). Specs cover both directions, including the negative control: omitting processedAt must still stamp now, or the positive test could pass while the field was ignored entirely. Co-Authored-By: Claude Fable 5 --- .../modules/payment/dto/create-payment.dto.ts | 27 ++++++ .../modules/payment/payment.service.spec.ts | 95 +++++++++++++++++++ .../src/modules/payment/payment.service.ts | 31 +++++- 3 files changed, 150 insertions(+), 3 deletions(-) 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(); From 5585001efd86dcad1713b810e50f573add43a662 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 00:15:37 +0000 Subject: [PATCH 2/2] chore: sync README test counts after out-of-band payment specs Co-authored-by: Charles Pizzato <311327716+modernitconsultants@users.noreply.github.com> --- README.md | 8 ++++---- docs/test-stats.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dc3f137..2d1793c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License -1509 Tests Passing 12 AI Agents +1514 Tests Passing 12 AI Agents

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