Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/api/src/modules/payment/dto/create-payment.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
IsOptional,
IsUUID,
IsEnum,
IsDateString,
MaxLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
Expand Down Expand Up @@ -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()
Expand All @@ -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;
}
95 changes: 95 additions & 0 deletions apps/api/src/modules/payment/payment.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
31 changes: 28 additions & 3 deletions apps/api/src/modules/payment/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
);
}

Expand All @@ -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();

Expand Down
Loading