Skip to content

PM-1110 report for payment load #69

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: dev
Choose a base branch
from
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
4 changes: 2 additions & 2 deletions src/api/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export class AdminController {
limit: 999,
});

const handles = await this.tcMembersService.getHandlesByUserIds(
const handlesMap = await this.tcMembersService.getMembersInfoByUserId(
result.data.winnings.map((d) => d.winnerId),
);

Expand All @@ -108,7 +108,7 @@ export class AdminController {
return {
id: item.id,
winnerId: item.winnerId,
handle: handles[`${item.winnerId}`] ?? item.winnerId,
handle: handlesMap[`${item.winnerId}`]?.handle ?? item.winnerId,
origin: item.origin,
category: item.category,
title: item.title,
Expand Down
2 changes: 2 additions & 0 deletions src/api/api.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { WinningsModule } from './winnings/winnings.module';
import { UserModule } from './user/user.module';
import { WalletModule } from './wallet/wallet.module';
import { WithdrawalModule } from './withdrawal/withdrawal.module';
import { ReportsModule } from './reports/reports.module';

@Module({
imports: [
Expand All @@ -29,6 +30,7 @@ import { WithdrawalModule } from './withdrawal/withdrawal.module';
UserModule,
WalletModule,
WithdrawalModule,
ReportsModule,
],
controllers: [HealthCheckController],
providers: [
Expand Down
9 changes: 7 additions & 2 deletions src/api/payment-providers/trolley.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,15 @@ export class TrolleyService {
return foundRecipient[0];
}

const userInfo = await this.tcMembersService.getMemberInfoByUserHandle(
const userInfo = await this.tcMembersService.getMemberInfoByHandle(
user.handle,
{ fields: BASIC_MEMBER_FIELDS },
BASIC_MEMBER_FIELDS,
);

if (!userInfo) {
throw new Error(`Member info for handle '${user.handle}' not found!`);
}

const address = (userInfo.addresses?.[0] ?? {}) as unknown as {
[key: string]: string;
};
Expand Down
39 changes: 39 additions & 0 deletions src/api/reports/reports.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Controller, Get, Query } from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiQuery,
} from '@nestjs/swagger';
import { ReportsService } from './reports.service';
import { ResponseDto } from 'src/dto/api-response.dto';
import {
PaymentsReportQueryDto,
PaymentsReportResponse,
} from 'src/dto/reports.dto';

@ApiTags('Reports')
@Controller('/reports')
@ApiBearerAuth()
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}

@Get('/payments')
@ApiOperation({
summary: 'Export search winnings result in csv file format',
description: 'Roles: Payment Admin, Payment Editor, Payment Viewer',
})
@ApiQuery({
type: PaymentsReportQueryDto,
})
@ApiResponse({
status: 200,
description: 'Export winnings successfully.',
type: ResponseDto<PaymentsReportResponse>,
})
async getPaymentsReport(@Query() query: PaymentsReportQueryDto) {
const report = await this.reportsService.getPaymentsReport(query);
return report;
}
}
11 changes: 11 additions & 0 deletions src/api/reports/reports.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
import { TopcoderModule } from 'src/shared/topcoder/topcoder.module';

@Module({
imports: [TopcoderModule],
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}
146 changes: 146 additions & 0 deletions src/api/reports/reports.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { Injectable } from '@nestjs/common';
import { Prisma, winnings_category } from '@prisma/client';
import { isEmpty, uniq } from 'lodash';
import { PaymentsReportQueryDto } from 'src/dto/reports.dto';
import { Logger } from 'src/shared/global';
import { PrismaService } from 'src/shared/global/prisma.service';
import { BASIC_MEMBER_FIELDS } from 'src/shared/topcoder';
import { TopcoderChallengesService } from 'src/shared/topcoder/challenges.service';
import { TopcoderMembersService } from 'src/shared/topcoder/members.service';

@Injectable()
export class ReportsService {
private readonly logger = new Logger(ReportsService.name);

constructor(
private readonly prisma: PrismaService,
private readonly membersService: TopcoderMembersService,
private readonly challengeService: TopcoderChallengesService,
) {}

private async buildPaymentReportQueryFilters(
filters: PaymentsReportQueryDto,
) {
const queryFilters: Prisma.paymentWhereInput = {};

if (filters.billingAccountIds) {
Object.assign(queryFilters, {
billing_account: { in: [...filters.billingAccountIds] },
});
}

if (filters.handles) {
const userIdsMap = await this.membersService.getMembersInfoByHandle(
filters.handles,
);

if (!isEmpty(userIdsMap)) {
Object.assign(queryFilters, {
winnings: {
winner_id: {
in: Object.values(userIdsMap).map((u) => `${u.userId ?? ''}`),
},
},
});
}
}

if (filters.challengeName) {
const challenges = await this.challengeService.searchByName(
filters.challengeName,
);

Object.assign(queryFilters, {
winnings: {
...queryFilters.winnings,
external_id: { in: challenges.map((c: { id: string }) => c.id) },
},
});
}

if (filters.startDate || filters.endDate) {
Object.assign(queryFilters, {
created_at: {
...(filters.startDate && { gte: filters.startDate }),
...(filters.endDate && { lte: filters.endDate }),
},
});
}

if (filters.minPaymentAmount || filters.maxPaymentAmount) {
Object.assign(queryFilters, {
total_amount: {
...(filters.minPaymentAmount && { gte: filters.minPaymentAmount }),
...(filters.maxPaymentAmount && { lte: filters.maxPaymentAmount }),
},
});
}

return queryFilters;
}

async getPaymentsReport(filters: PaymentsReportQueryDto) {
this.logger.debug('Starting getPaymentsReport with filters:', filters);

const queryFilters = await this.buildPaymentReportQueryFilters(filters);

const payments = await this.prisma.payment.findMany({
where: {
...queryFilters,
},
select: {
payment_id: true,
created_at: true,
billing_account: true,
payment_status: true,
challenge_fee: true,
total_amount: true,
winnings: {
select: {
external_id: true,
winner_id: true,
category: true,
},
},
},
});

this.logger.debug(`Fetched ${payments.length} payments from the database`);

const userIds = uniq(payments.map((p) => p.winnings.winner_id as string));
const challengeIds = uniq(
payments.map((p) => p.winnings.external_id as string).filter(Boolean),
);

this.logger.debug(`Extracted ${userIds.length} unique user IDs`);
this.logger.debug(`Extracted ${challengeIds.length} unique challenge IDs`);

const [membersMap, challengeNamesMap] = await Promise.all([
this.membersService.getMembersInfoByUserId(userIds, BASIC_MEMBER_FIELDS),
this.challengeService.getChallengesNameByChallengeIds(challengeIds),
]);

this.logger.debug('Fetched member information and challenge names');

const result = payments.map((payment) => ({
billingAccountId: payment.billing_account,
challengeName:
challengeNamesMap[payment.winnings.external_id as string] ?? '',
challengeId: payment.winnings.external_id as string,
paymentDate: payment.created_at,
paymentId: payment.payment_id,
paymentStatus: payment.payment_status,
winnerId: payment.winnings.winner_id,
winnerHandle: membersMap[payment.winnings.winner_id]?.handle ?? '',
winnerFirstName: membersMap[payment.winnings.winner_id]?.firstName ?? '',
winnerLastName: membersMap[payment.winnings.winner_id]?.lastName ?? '',
isTask: payment.winnings.category === winnings_category.TASK_PAYMENT,
challengeFee: payment.challenge_fee,
paymentAmount: payment.total_amount,
}));

this.logger.debug('Mapped payments to the final report format');

return result;
}
}
7 changes: 4 additions & 3 deletions src/api/winnings/winnings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ export class WinningsService {

private async sendSetupEmailNotification(userId: string, amount: number) {
this.logger.debug(`Fetching member info for user handle: ${userId}`);
const member = await this.tcMembersService.getMemberInfoByUserId(userId, {
fields: BASIC_MEMBER_FIELDS,
});
const member = await this.tcMembersService.getMemberInfoByUserId(
userId,
BASIC_MEMBER_FIELDS,
);

if (!member) {
this.logger.warn(
Expand Down
4 changes: 2 additions & 2 deletions src/api/withdrawal/withdrawal.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,9 @@ export class WithdrawalService {
let userInfo: { email: string };
this.logger.debug(`Getting user details for user ${userHandle}(${userId})`);
try {
userInfo = (await this.tcMembersService.getMemberInfoByUserHandle(
userInfo = (await this.tcMembersService.getMemberInfoByHandle(
userHandle,
{ fields: [MEMBER_FIELDS.email] },
[MEMBER_FIELDS.email],
)) as { email: string };
} catch {
throw new Error('Failed to fetch UserInfo for withdrawal!');
Expand Down
104 changes: 104 additions & 0 deletions src/dto/reports.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsOptional,
IsString,
IsNotEmpty,
IsNumber,
IsDateString,
} from 'class-validator';

const transformArray = ({ value }: { value: string }) =>
Array.isArray(value) ? value : [value];

export class PaymentsReportQueryDto {
@ApiProperty({
description:
'List of billing account IDs associated with the payments to retrieve',
example: ['80001012'],
})
@IsOptional()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@Transform(transformArray)
billingAccountIds?: string[];

@ApiProperty({
description: 'Challenge name to search for',
example: ['Task Payment for member'],
})
@IsOptional()
@IsString()
@IsNotEmpty()
challengeName?: string;

@ApiProperty({
description: 'List of challenge IDs',
example: ['e74c3e37-73c9-474e-a838-a38dd4738906'],
})
@IsOptional()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@Transform(transformArray)
challengeIds?: string[];

@ApiProperty({
description: 'Start date for the report query in ISO format',
example: '2023-01-01T00:00:00.000Z',
})
@IsOptional()
@IsDateString()
startDate?: Date;

@ApiProperty({
description: 'End date for the report query in ISO format',
example: '2023-01-31T23:59:59.000Z',
})
@IsOptional()
@IsDateString()
endDate?: Date;

@ApiProperty({
description: 'List of user handles',
example: ['user_01', 'user_02'],
})
@IsOptional()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@Transform(transformArray)
handles?: string[];

@ApiProperty({
description: 'Minimum payment amount for filtering the report',
example: 100,
})
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseFloat(value))
minPaymentAmount?: number;

@ApiProperty({
description: 'Maximum payment amount for filtering the report',
example: 1000,
})
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseFloat(value))
maxPaymentAmount?: number;
}

export class PaymentsReportResponse {
billingAccountId: string;
challengeName: string;
challengeId: string;
paymentDate: string;
paymentId: string;
paymentStatus: string;
winnerId: string;
winnerHandle: string;
winnerFirstName: string;
winnerLastName: string;
isTask: boolean;
challengeFee: number;
paymentAmount: number;
}
Loading