Skip to content
Merged
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
42 changes: 37 additions & 5 deletions backend/src/payments/dto/payment-failure-query.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,36 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import {
IsDateString,
IsOptional,
IsString,
IsUUID,
MaxLength,
Validate,
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { PaginationDto } from '../../common/dto/pagination.dto';

export const MAX_DATE_RANGE_DAYS = 366;

/** toDate must not be before fromDate, and the range must stay bounded. */
@ValidatorConstraint({ name: 'dateRange', async: false })
export class DateRangeConstraint implements ValidatorConstraintInterface {
validate(toDate: unknown, args: ValidationArguments): boolean {
const { fromDate } = args.object as { fromDate?: string };
if (!fromDate || typeof toDate !== 'string') return true;
const from = new Date(fromDate).getTime();
const to = new Date(toDate).getTime();
if (Number.isNaN(from) || Number.isNaN(to)) return true; // IsDateString reports it
return from <= to && to - from <= MAX_DATE_RANGE_DAYS * 86_400_000;
}

defaultMessage(): string {
return `toDate must not be before fromDate, and the range cannot exceed ${MAX_DATE_RANGE_DAYS} days`;
}
}

export class PaymentFailureQueryDto extends PaginationDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
Expand All @@ -21,17 +50,20 @@ export class PaymentFailureQueryDto extends PaginationDto {

@ApiPropertyOptional({ example: '2026-07-01' })
@IsOptional()
@IsString()
@IsDateString()
fromDate?: string;

@ApiPropertyOptional({ example: '2026-07-31' })
@IsOptional()
@IsString()
@IsDateString()
@Validate(DateRangeConstraint)
toDate?: string;

@ApiPropertyOptional({ description: 'Free-text search across error codes, messages, and metadata' })
@ApiPropertyOptional({
description: 'Free-text search across error codes, messages, and metadata',
})
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
}
}
124 changes: 122 additions & 2 deletions backend/src/payments/payments.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { Repository } from 'typeorm';
import { PaymentsService } from './payments.service';
import { InitializePaymentProvider } from './providers/initialize-payment.provider';
import { HandleWebhookProvider } from './providers/handle-webhook.provider';
import { RefundPaymentProvider } from './providers/refund-payment.provider';
import { FindPaymentsProvider } from './providers/find-payments.provider';
import {
FindPaymentsProvider,
MAX_PAGE,
PaymentQuery,
} from './providers/find-payments.provider';
import { PaymentFailureQueryDto } from './dto/payment-failure-query.dto';
import { Payment } from './entities/payment.entity';
import { PaymentStatus } from './enums/payment-status.enum';
import { UserRole } from '../users/enums/userRoles.enum';

describe('PaymentsService', () => {
let service: PaymentsService;
let loggerLogSpy: jest.SpyInstance;
Expand Down Expand Up @@ -143,3 +152,114 @@ describe('PaymentsService', () => {
});
});
});


describe('payment query validation', () => {
const errorsFor = async (plain: Record<string, unknown>) =>
(await validate(plainToInstance(PaymentQuery, plain))).map(
(e) => e.property,
);
const status = Object.values(PaymentStatus)[0];

it('accepts valid filters', async () => {
expect(
await errorsFor({
status,
fromDate: '2026-07-01',
toDate: '2026-07-31',
page: 1,
limit: 20,
}),
).toEqual([]);
});

it('rejects an unsupported status', async () => {
expect(await errorsFor({ status: 'not-a-status' })).toContain('status');
});

it('rejects malformed dates', async () => {
expect(await errorsFor({ fromDate: 'banana' })).toContain('fromDate');
expect(await errorsFor({ toDate: '2026-13-99' })).toContain('toDate');
});

it('rejects toDate before fromDate', async () => {
expect(
await errorsFor({ fromDate: '2026-07-31', toDate: '2026-07-01' }),
).toContain('toDate');
});

it('rejects a date range longer than the maximum', async () => {
expect(
await errorsFor({ fromDate: '2020-01-01', toDate: '2026-01-01' }),
).toContain('toDate');
});

it('rejects out-of-bounds pages', async () => {
expect(await errorsFor({ limit: 1000 })).toContain('limit');
expect(await errorsFor({ page: 0 })).toContain('page');
expect(await errorsFor({ page: MAX_PAGE + 1 })).toContain('page');
});

it('applies the same date validation to payment failure queries', async () => {
const errors = await validate(
plainToInstance(PaymentFailureQueryDto, {
fromDate: 'banana',
toDate: '2026-07-01',
}),
);
expect(errors.map((e) => e.property)).toContain('fromDate');
});
});

describe('FindPaymentsProvider scoping', () => {
const makeProvider = () => {
const qb: Record<string, jest.Mock> = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
const repo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const provider = new FindPaymentsProvider(
repo as unknown as Repository<Payment>,
);
return { provider, qb };
};
const filters = Object.assign(new PaymentQuery(), {
status: Object.values(PaymentStatus)[0],
fromDate: '2026-07-01',
toDate: '2026-07-31',
});

it('scopes a regular user to their own payments even with filters applied', async () => {
const { provider, qb } = makeProvider();
await provider.findAll(filters, 'user-1', UserRole.USER);
expect(qb.where).toHaveBeenCalledWith('payment.userId = :userId', {
userId: 'user-1',
});
expect(qb.andWhere).toHaveBeenCalledTimes(3); // status, fromDate, toDate
});

it.each([UserRole.STAFF, UserRole.ADMIN, UserRole.SUPER_ADMIN])(
'%s is not scoped to a single user',
async (role) => {
const { provider, qb } = makeProvider();
await provider.findAll(filters, 'someone', role);
expect(qb.where).not.toHaveBeenCalled();
},
);

it('includes the whole day for a date-only toDate', async () => {
const { provider, qb } = makeProvider();
await provider.findAll(
Object.assign(new PaymentQuery(), { toDate: '2026-07-31' }),
'u',
UserRole.ADMIN,
);
expect(qb.andWhere).toHaveBeenCalledWith('payment.createdAt <= :toDate', {
toDate: new Date('2026-07-31T23:59:59.999Z'),
});
});
});
71 changes: 69 additions & 2 deletions backend/src/payments/providers/find-payments.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,36 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Payment } from '../entities/payment.entity';
import { PaymentStatus } from '../enums/payment-status.enum';
import { UserRole } from '../../users/enums/userRoles.enum';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsUUID } from 'class-validator';
import {
IsDateString,
IsEnum,
IsInt,
IsOptional,
IsUUID,
Max,
Min,
Validate,
} from 'class-validator';
import { Type } from 'class-transformer';
import {
DEFAULT_LIMIT,
DEFAULT_PAGE,
PaginationDto,
paginatedResponse,
} from '../../common/dto/pagination.dto';
import { DateRangeConstraint } from '../dto/payment-failure-query.dto';

export const MAX_PAGE = 10_000;

/** A date-only toDate ("2026-07-31") should include that whole day. */
function endOfRange(value: string): Date {
return /^\d{4}-\d{2}-\d{2}$/.test(value)
? new Date(`${value}T23:59:59.999Z`)
: new Date(value);
}

/**
* Query parameters for listing payments (BE-15 acceptance).
Expand All @@ -24,6 +45,35 @@ export class PaymentQuery extends PaginationDto {
@IsOptional()
@IsUUID()
bookingId?: string;

@ApiPropertyOptional({
default: DEFAULT_PAGE,
minimum: 1,
maximum: MAX_PAGE,
example: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: 'page must be an integer' })
@Min(1, { message: 'page must be greater than or equal to 1' })
@Max(MAX_PAGE, { message: `page must be less than or equal to ${MAX_PAGE}` })
page: number = DEFAULT_PAGE;

@ApiPropertyOptional({ enum: PaymentStatus })
@IsOptional()
@IsEnum(PaymentStatus)
status?: PaymentStatus;

@ApiPropertyOptional({ example: '2026-07-01' })
@IsOptional()
@IsDateString()
fromDate?: string;

@ApiPropertyOptional({ example: '2026-07-31' })
@IsOptional()
@IsDateString()
@Validate(DateRangeConstraint)
toDate?: string;
}

@Injectable()
Expand Down Expand Up @@ -59,9 +109,26 @@ export class FindPaymentsProvider {
});
}

if (query.status) {
qb.andWhere('payment.status = :status', { status: query.status });
}

if (query.fromDate) {
qb.andWhere('payment.createdAt >= :fromDate', {
fromDate: new Date(query.fromDate),
});
}

if (query.toDate) {
qb.andWhere('payment.createdAt <= :toDate', {
toDate: endOfRange(query.toDate),
});
}

qb.orderBy('payment.createdAt', 'DESC').skip(skip).take(limit);

const [data, total] = await qb.getManyAndCount();

return paginatedResponse(
'Payments retrieved successfully',
data,
Expand Down Expand Up @@ -91,4 +158,4 @@ export class FindPaymentsProvider {

return qb.getOne();
}
}
}
Loading