Skip to content

Commit 4fa8268

Browse files
Merge pull request #730 from mahajanmahesh935/ReportsFilter
TASK #00000 : Add status filter to transaction report API
2 parents c04aedb + b9679cc commit 4fa8268

2 files changed

Lines changed: 228 additions & 55 deletions

File tree

src/payments/payments.controller.ts

Lines changed: 120 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
import {
2020
ApiTags,
2121
ApiOperation,
22-
ApiResponse,
2322
ApiBody,
2423
ApiCreatedResponse,
2524
ApiOkResponse,
@@ -42,7 +41,7 @@ import { PaymentReportResponseDto } from './dtos/payment-report.dto';
4241
@ApiTags('Payments')
4342
@Controller('payments')
4443
export class PaymentsController {
45-
constructor(private paymentService: PaymentService) {}
44+
constructor(private readonly paymentService: PaymentService) {}
4645

4746
@Post('initiate')
4847
@UseFilters(new AllExceptionsFilter(APIID.PAYMENT_INITIATE))
@@ -193,29 +192,55 @@ export class PaymentsController {
193192
);
194193
}
195194

196-
@Get('report/:contextId')
195+
@Post('report/:contextId')
197196
@UseFilters(new AllExceptionsFilter(APIID.PAYMENT_STATUS))
198-
@ApiOperation({ summary: 'Get payment report by contextId with pagination' })
199-
@ApiQuery({
200-
name: 'limit',
201-
required: false,
202-
type: Number,
203-
description: 'Number of records to return (default: 50, max: 1000)',
204-
example: 50,
205-
})
206-
@ApiQuery({
207-
name: 'offset',
208-
required: false,
209-
type: Number,
210-
description: 'Number of records to skip (default: 0)',
211-
example: 0,
212-
})
213-
@ApiQuery({
214-
name: 'search',
215-
required: false,
216-
type: String,
217-
description: 'Free text search on firstName, lastName, and email (case-insensitive)',
218-
example: 'john',
197+
@ApiOperation({ summary: 'Get payment report by contextId with filters and pagination' })
198+
@ApiBody({
199+
schema: {
200+
type: 'object',
201+
properties: {
202+
limit: {
203+
type: 'number',
204+
example: 50,
205+
description: 'Number of records to return (default: 50, max: 1000)',
206+
},
207+
offset: {
208+
type: 'number',
209+
example: 0,
210+
description: 'Number of records to skip (default: 0)',
211+
},
212+
search: {
213+
type: 'string',
214+
example: 'john',
215+
description:
216+
'Free text search on firstName, lastName, and email (case-insensitive)',
217+
},
218+
status: {
219+
oneOf: [
220+
{ type: 'string', example: 'SUCCESS,FAILED' },
221+
{
222+
type: 'array',
223+
items: { type: 'string', enum: ['SUCCESS', 'INITIATED', 'FAILED'] },
224+
example: ['SUCCESS', 'FAILED'],
225+
},
226+
],
227+
description:
228+
'Filter by transaction status. Accepts SUCCESS, INITIATED, FAILED',
229+
},
230+
certificateGenerated: {
231+
oneOf: [
232+
{ type: 'boolean', example: true },
233+
{ type: 'string', example: 'true' },
234+
],
235+
description: 'Filter by certificate generation status (true/false)',
236+
},
237+
couponCode: {
238+
type: 'string',
239+
example: 'WELCOME10',
240+
description: 'Filter by applied coupon code (case-insensitive exact match)',
241+
},
242+
},
243+
},
219244
})
220245
@ApiOkResponse({
221246
description: 'Payment report retrieved successfully',
@@ -224,9 +249,12 @@ export class PaymentsController {
224249
@ApiBadRequestResponse({ description: 'Invalid pagination parameters' })
225250
async getPaymentReport(
226251
@Param('contextId', ParseUUIDPipe) contextId: string,
227-
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
228-
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
229-
@Query('search') search?: string,
252+
@Body('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
253+
@Body('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
254+
@Body('search') search?: string,
255+
@Body('status') status?: string | string[],
256+
@Body('certificateGenerated') certificateGenerated?: string | boolean,
257+
@Body('couponCode') couponCode?: string,
230258
): Promise<PaymentReportResponseDto> {
231259
// Validate pagination parameters
232260
if (limit < 1 || limit > 1000) {
@@ -237,12 +265,33 @@ export class PaymentsController {
237265
}
238266

239267
const searchTerm = typeof search === 'string' ? search.trim() : undefined;
268+
const normalizedStatuses = this.normalizeStatuses(status);
269+
270+
const allowedStatuses = new Set(['SUCCESS', 'INITIATED', 'FAILED']);
271+
const invalidStatuses = normalizedStatuses.filter(
272+
(value) => !allowedStatuses.has(value),
273+
);
274+
if (invalidStatuses.length > 0) {
275+
throw new BadRequestException(
276+
`Invalid status filter(s): ${invalidStatuses.join(', ')}. Allowed values are SUCCESS, INITIATED, FAILED`,
277+
);
278+
}
279+
280+
const certificateGeneratedFilter =
281+
this.parseBooleanLikeQueryParam(certificateGenerated);
282+
const couponCodeFilter =
283+
typeof couponCode === 'string' && couponCode.trim().length > 0
284+
? couponCode.trim()
285+
: undefined;
240286

241287
const result = await this.paymentService.getPaymentReportByContextId(
242288
contextId,
243289
limit,
244290
offset,
245291
searchTerm,
292+
normalizedStatuses.length > 0 ? normalizedStatuses : undefined,
293+
certificateGeneratedFilter,
294+
couponCodeFilter,
246295
);
247296

248297
return {
@@ -253,4 +302,48 @@ export class PaymentsController {
253302
hasMore: offset + result.data.length < result.totalCount,
254303
};
255304
}
305+
306+
private parseBooleanLikeQueryParam(value?: string | boolean): boolean | undefined {
307+
if (typeof value === 'boolean') {
308+
return value;
309+
}
310+
311+
if (typeof value !== 'string') {
312+
return undefined;
313+
}
314+
315+
const normalized = value.trim().toLowerCase();
316+
if (normalized === '') {
317+
return undefined;
318+
}
319+
320+
if (normalized === 'true') {
321+
return true;
322+
}
323+
324+
if (normalized === 'false') {
325+
return false;
326+
}
327+
328+
throw new BadRequestException(
329+
'certificateGenerated must be one of: true, false',
330+
);
331+
}
332+
333+
private normalizeStatuses(status?: string | string[]): string[] {
334+
if (Array.isArray(status)) {
335+
return status
336+
.map((value) => value.trim().toUpperCase())
337+
.filter((value) => value.length > 0);
338+
}
339+
340+
if (typeof status === 'string') {
341+
return status
342+
.split(',')
343+
.map((value) => value.trim().toUpperCase())
344+
.filter((value) => value.length > 0);
345+
}
346+
347+
return [];
348+
}
256349
}

src/payments/services/payment.service.ts

Lines changed: 108 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
Inject,
88
} from '@nestjs/common';
99
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
10-
import { DataSource, EntityManager, Repository, In } from 'typeorm';
10+
import { DataSource, EntityManager, Repository, In, SelectQueryBuilder } from 'typeorm';
1111
import { PaymentProvider } from '../interfaces/payment-provider.interface';
1212
import { PaymentIntentService } from './payment-intent.service';
1313
import { PaymentTransactionService } from './payment-transaction.service';
@@ -596,27 +596,27 @@ export class PaymentService {
596596
limit: number = 50,
597597
offset: number = 0,
598598
search?: string,
599+
statusFilters?: string[],
600+
certificateGenerated?: boolean,
601+
couponCode?: string,
599602
): Promise<{ data: PaymentReportItemDto[]; totalCount: number }> {
600603
const searchTerm =
601604
typeof search === 'string' && search.trim().length > 0
602605
? search.trim().toLowerCase()
603606
: undefined;
607+
const transactionStatuses =
608+
this.mapReportStatusFiltersToTransactionStatuses(statusFilters);
604609
const countQb = this.dataSource
605610
.getRepository(PaymentTransaction)
606-
.createQueryBuilder('transaction')
607-
.innerJoin('transaction.paymentIntent', 'intent')
608-
.innerJoin('intent.targets', 'target')
609-
.where('target.contextId = :contextId', { contextId });
610-
611-
if (searchTerm) {
612-
const searchPattern = `%${searchTerm}%`;
613-
countQb
614-
.innerJoin(User, 'user', 'user.userId = intent.userId')
615-
.andWhere(
616-
'(LOWER(COALESCE(user.firstName, \'\')) LIKE :searchPattern OR LOWER(COALESCE(user.lastName, \'\')) LIKE :searchPattern OR LOWER(COALESCE(user.email, \'\')) LIKE :searchPattern)',
617-
{ searchPattern },
618-
);
619-
}
611+
.createQueryBuilder('transaction');
612+
this.applyReportFilters(
613+
countQb,
614+
contextId,
615+
searchTerm,
616+
transactionStatuses,
617+
certificateGenerated,
618+
couponCode,
619+
);
620620

621621
const countResult = await countQb
622622
.select('COUNT(DISTINCT transaction.id)', 'cnt')
@@ -636,24 +636,19 @@ export class PaymentService {
636636
.getRepository(PaymentTransaction)
637637
.createQueryBuilder('transaction')
638638
.select('transaction.id', 'id')
639-
.innerJoin('transaction.paymentIntent', 'intent')
640-
.innerJoin('intent.targets', 'target')
641-
.where('target.contextId = :contextId', { contextId })
642639
.groupBy('transaction.id')
643640
.orderBy('MAX(transaction.createdAt)', 'DESC')
644641
.addOrderBy('transaction.id', 'ASC')
645642
.offset(offset)
646643
.limit(limit);
647-
648-
if (searchTerm) {
649-
const searchPattern = `%${searchTerm}%`;
650-
idsQb
651-
.innerJoin(User, 'user', 'user.userId = intent.userId')
652-
.andWhere(
653-
'(LOWER(COALESCE(user.firstName, \'\')) LIKE :searchPattern OR LOWER(COALESCE(user.lastName, \'\')) LIKE :searchPattern OR LOWER(COALESCE(user.email, \'\')) LIKE :searchPattern)',
654-
{ searchPattern },
655-
);
656-
}
644+
this.applyReportFilters(
645+
idsQb,
646+
contextId,
647+
searchTerm,
648+
transactionStatuses,
649+
certificateGenerated,
650+
couponCode,
651+
);
657652

658653
const idRows = await idsQb.getRawMany();
659654
const orderedIds = idRows.map((row) => row.id as string);
@@ -752,6 +747,91 @@ export class PaymentService {
752747
};
753748
}
754749

750+
private applyReportFilters(
751+
qb: SelectQueryBuilder<PaymentTransaction>,
752+
contextId: string,
753+
searchTerm?: string,
754+
transactionStatuses?: PaymentTransactionStatus[],
755+
certificateGenerated?: boolean,
756+
couponCode?: string,
757+
): void {
758+
qb
759+
.innerJoin('transaction.paymentIntent', 'intent')
760+
.innerJoin('intent.targets', 'target')
761+
.where('target.contextId = :contextId', { contextId });
762+
763+
if (searchTerm) {
764+
const searchPattern = `%${searchTerm}%`;
765+
qb
766+
.innerJoin(User, 'user', 'user.userId = intent.userId')
767+
.andWhere(
768+
'(LOWER(COALESCE(user.firstName, \'\')) LIKE :searchPattern OR LOWER(COALESCE(user.lastName, \'\')) LIKE :searchPattern OR LOWER(COALESCE(user.email, \'\')) LIKE :searchPattern)',
769+
{ searchPattern },
770+
);
771+
}
772+
773+
if (transactionStatuses && transactionStatuses.length > 0) {
774+
qb.andWhere('transaction.status IN (:...transactionStatuses)', {
775+
transactionStatuses,
776+
});
777+
}
778+
779+
if (typeof certificateGenerated === 'boolean') {
780+
const lockedTargetSubquery = qb
781+
.subQuery()
782+
.select('1')
783+
.from(PaymentTarget, 'target_unlock_filter')
784+
.where('target_unlock_filter.paymentIntentId = intent.id')
785+
.andWhere('target_unlock_filter.contextId = :contextId')
786+
.andWhere('target_unlock_filter.unlockStatus != :unlockedStatus')
787+
.getQuery();
788+
789+
if (certificateGenerated) {
790+
qb.andWhere(`NOT EXISTS ${lockedTargetSubquery}`, {
791+
unlockedStatus: PaymentTargetUnlockStatus.UNLOCKED,
792+
});
793+
} else {
794+
qb.andWhere(`EXISTS ${lockedTargetSubquery}`, {
795+
unlockedStatus: PaymentTargetUnlockStatus.UNLOCKED,
796+
});
797+
}
798+
}
799+
800+
if (couponCode) {
801+
qb.andWhere(
802+
"LOWER(COALESCE(intent.metadata->>'couponCode', '')) = :couponCode",
803+
{ couponCode: couponCode.toLowerCase() },
804+
);
805+
}
806+
}
807+
808+
private mapReportStatusFiltersToTransactionStatuses(
809+
statusFilters?: string[],
810+
): PaymentTransactionStatus[] | undefined {
811+
if (!statusFilters || statusFilters.length === 0) {
812+
return undefined;
813+
}
814+
815+
const statuses = new Set<PaymentTransactionStatus>();
816+
statusFilters.forEach((status) => {
817+
switch (status) {
818+
case 'SUCCESS':
819+
statuses.add(PaymentTransactionStatus.SUCCESS);
820+
break;
821+
case 'INITIATED':
822+
statuses.add(PaymentTransactionStatus.INITIATED);
823+
break;
824+
case 'FAILED':
825+
statuses.add(PaymentTransactionStatus.FAILED);
826+
break;
827+
default:
828+
break;
829+
}
830+
});
831+
832+
return Array.from(statuses);
833+
}
834+
755835
private computeTargetUnlockedForContext(
756836
intent: PaymentIntent,
757837
reportContextId: string,

0 commit comments

Comments
 (0)