Skip to content

Commit cd7df02

Browse files
committed
Merge branch 'v6' of github.com:topcoder-platform/tc-finance-api into PM-2087_use-v6-apis
2 parents 9dfe7b3 + 90723b9 commit cd7df02

18 files changed

+723
-46
lines changed

.circleci/config.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
environment:
5252
DEPLOY_ENV: 'DEV'
5353
LOGICAL_ENV: 'dev'
54-
APPNAME: 'tc-finance-api'
54+
APPNAME: 'finance-api-v6'
5555
DEPLOYMENT_ENVIRONMENT: 'dev'
5656
steps: *build_and_deploy_steps
5757

@@ -60,7 +60,7 @@ jobs:
6060
environment:
6161
DEPLOY_ENV: 'PROD'
6262
LOGICAL_ENV: 'prod'
63-
APPNAME: 'tc-finance-api'
63+
APPNAME: 'finance-api-v6'
6464
DEPLOYMENT_ENVIRONMENT: 'prod'
6565
steps: *build_and_deploy_steps
6666

@@ -74,9 +74,10 @@ workflows:
7474
branches:
7575
only:
7676
- dev
77+
- v6
7778
- 'build-prod':
7879
context: org-global
7980
filters:
8081
branches:
8182
only:
82-
- master
83+
- master

src/api/api.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { WinningsModule } from './winnings/winnings.module';
1717
import { UserModule } from './user/user.module';
1818
import { WalletModule } from './wallet/wallet.module';
1919
import { WithdrawalModule } from './withdrawal/withdrawal.module';
20+
import { ChallengesModule } from './challenges/challenges.module';
2021

2122
@Module({
2223
imports: [
@@ -29,6 +30,7 @@ import { WithdrawalModule } from './withdrawal/withdrawal.module';
2930
UserModule,
3031
WalletModule,
3132
WithdrawalModule,
33+
ChallengesModule,
3234
],
3335
controllers: [HealthCheckController],
3436
providers: [
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Controller, Post, Param, HttpCode, HttpStatus } from '@nestjs/common';
2+
import {
3+
ApiOperation,
4+
ApiParam,
5+
ApiTags,
6+
ApiResponse,
7+
ApiBearerAuth,
8+
} from '@nestjs/swagger';
9+
import { UserInfo } from 'src/dto/user.type';
10+
import { M2mScope } from 'src/core/auth/auth.constants';
11+
import { AllowedM2mScope, M2M, User } from 'src/core/auth/decorators';
12+
import { ResponseDto, ResponseStatusType } from 'src/dto/api-response.dto';
13+
import { ChallengesService } from './challenges.service';
14+
15+
@ApiTags('Challenges')
16+
@Controller('/challenges')
17+
@ApiBearerAuth()
18+
export class ChallengesController {
19+
constructor(
20+
private readonly challengesService: ChallengesService,
21+
) {}
22+
23+
@Post('/:challengeId')
24+
@M2M()
25+
@AllowedM2mScope(M2mScope.CreatePayments)
26+
@ApiOperation({
27+
summary: 'Create winning with payments.',
28+
description: 'User must have "create:payments" scope to access.',
29+
})
30+
@ApiParam({
31+
name: 'challengeId',
32+
description: 'The ID of the challenge',
33+
example: '2ccba36d-8db7-49da-94c9-b6c5b7bf47fb',
34+
})
35+
@ApiResponse({
36+
status: 201,
37+
description: 'Create winnings successfully.',
38+
type: ResponseDto<string>,
39+
})
40+
@HttpCode(HttpStatus.CREATED)
41+
async createWinnings(
42+
@Param('challengeId') challengeId: string,
43+
@User() user: UserInfo,
44+
): Promise<ResponseDto<string>> {
45+
const result = new ResponseDto<string>();
46+
47+
try {
48+
await this.challengesService.generateChallengePayments(
49+
challengeId,
50+
user.id,
51+
);
52+
result.status = ResponseStatusType.SUCCESS;
53+
} catch (e) {
54+
result.error = e;
55+
result.status = ResponseStatusType.ERROR;
56+
}
57+
58+
return result;
59+
}
60+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Module } from '@nestjs/common';
2+
import { TopcoderModule } from 'src/shared/topcoder/topcoder.module';
3+
import { ChallengesService } from './challenges.service';
4+
import { ChallengesController } from './challenges.controller';
5+
import { WinningsModule } from '../winnings/winnings.module';
6+
import { WinningsRepository } from '../repository/winnings.repo';
7+
8+
@Module({
9+
imports: [TopcoderModule, WinningsModule],
10+
controllers: [ChallengesController],
11+
providers: [
12+
ChallengesService,
13+
WinningsRepository,
14+
],
15+
})
16+
export class ChallengesModule {}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { includes, isEmpty, sortBy, find, camelCase, groupBy } from 'lodash';
2+
import { Injectable } from '@nestjs/common';
3+
import { ENV_CONFIG } from 'src/config';
4+
import { Logger } from 'src/shared/global';
5+
import { Challenge, ChallengeResource, ResourceRole } from './models';
6+
import { BillingAccountsService } from 'src/shared/topcoder/billing-accounts.service';
7+
import { TopcoderM2MService } from 'src/shared/topcoder/topcoder-m2m.service';
8+
import { ChallengeStatuses } from 'src/dto/challenge.dto';
9+
import { WinningsService } from '../winnings/winnings.service';
10+
import {
11+
WinningRequestDto,
12+
WinningsCategory,
13+
WinningsType,
14+
} from 'src/dto/winning.dto';
15+
import { WinningsRepository } from '../repository/winnings.repo';
16+
17+
const placeToOrdinal = (place: number) => {
18+
if (place === 1) return '1st';
19+
if (place === 2) return '2nd';
20+
if (place === 3) return '3rd';
21+
22+
return `${place}th`;
23+
};
24+
25+
const { TOPCODER_API_V6_BASE_URL: TC_API_BASE, TGBillingAccounts } = ENV_CONFIG;
26+
27+
@Injectable()
28+
export class ChallengesService {
29+
private readonly logger = new Logger(ChallengesService.name);
30+
31+
constructor(
32+
private readonly m2MService: TopcoderM2MService,
33+
private readonly baService: BillingAccountsService,
34+
private readonly winningsService: WinningsService,
35+
private readonly winningsRepo: WinningsRepository,
36+
) {}
37+
38+
async getChallenge(challengeId: string) {
39+
const requestUrl = `${TC_API_BASE}/challenges/${challengeId}`;
40+
41+
try {
42+
const challenge = await this.m2MService.m2mFetch<Challenge>(requestUrl);
43+
this.logger.log(JSON.stringify(challenge, null, 2));
44+
return challenge;
45+
} catch (e) {
46+
this.logger.error(
47+
`Challenge ${challengeId} details couldn't be fetched!`,
48+
e,
49+
);
50+
}
51+
}
52+
53+
async getChallengeResources(challengeId: string) {
54+
try {
55+
const resources = await this.m2MService.m2mFetch<ChallengeResource[]>(
56+
`${TC_API_BASE}/resources?challengeId=${challengeId}`,
57+
);
58+
const resourceRoles = await this.m2MService.m2mFetch<ResourceRole[]>(
59+
`${TC_API_BASE}/resource-roles`,
60+
);
61+
62+
const rolesMap = resourceRoles.reduce(
63+
(map, role) => {
64+
map[role.id] = camelCase(role.name);
65+
return map;
66+
},
67+
{} as { [key: string]: string },
68+
);
69+
70+
return groupBy(resources, (r) => rolesMap[r.roleId]) as {
71+
[role: string]: ChallengeResource[];
72+
};
73+
} catch (e) {
74+
this.logger.error(
75+
`Challenge resources for challenge ${challengeId} couldn't be fetched!`,
76+
e,
77+
);
78+
}
79+
}
80+
81+
async getChallengePayments(challenge: Challenge) {
82+
this.logger.log(
83+
`Generating payments for challenge ${challenge.name} (${challenge.id}).`,
84+
);
85+
const challengeResources = await this.getChallengeResources(challenge.id);
86+
87+
if (!challengeResources || isEmpty(challengeResources)) {
88+
throw new Error('Missing challenge resources!');
89+
}
90+
91+
const payments = [] as {
92+
handle: string;
93+
amount: number;
94+
userId: string;
95+
type: WinningsCategory;
96+
description?: string;
97+
}[];
98+
99+
const { prizeSets, winners, reviewers } = challenge;
100+
101+
// generate placement payments
102+
const placementPrizes = sortBy(
103+
find(prizeSets, { type: 'PLACEMENT' })?.prizes,
104+
'value',
105+
);
106+
if (placementPrizes.length < winners.length) {
107+
throw new Error(
108+
'Task has incorrect number of placement prizes! There are more winners than prizes!',
109+
);
110+
}
111+
112+
winners.forEach((winner) => {
113+
payments.push({
114+
handle: winner.handle,
115+
amount: placementPrizes[winner.placement - 1].value,
116+
userId: winner.userId.toString(),
117+
type: challenge.task.isTask
118+
? WinningsCategory.TASK_PAYMENT
119+
: WinningsCategory.CONTEST_PAYMENT,
120+
description:
121+
challenge.type === 'Task'
122+
? challenge.name
123+
: `${challenge.name} - ${placeToOrdinal(winner.placement)} Place`,
124+
});
125+
});
126+
127+
// generate copilot payments
128+
const copilotPrizes = find(prizeSets, { type: 'COPILOT' })?.prizes ?? [];
129+
if (copilotPrizes.length) {
130+
const copilots = challengeResources.copilot;
131+
132+
if (!copilots?.length) {
133+
throw new Error('Task has a copilot prize but no copilot assigned!');
134+
}
135+
136+
copilots.forEach((copilot) => {
137+
payments.push({
138+
handle: copilot.memberHandle,
139+
amount: copilotPrizes[0].value,
140+
userId: copilot.memberId.toString(),
141+
type: WinningsCategory.COPILOT_PAYMENT,
142+
});
143+
});
144+
}
145+
146+
// generate reviewer payments
147+
const firstPlacePrize = placementPrizes[0].value;
148+
const challengeReviewer = find(reviewers, { isMemberReview: true });
149+
150+
if (challengeReviewer && challengeResources.reviewer) {
151+
challengeResources.reviewer?.forEach((reviewer) => {
152+
payments.push({
153+
handle: reviewer.memberHandle,
154+
userId: reviewer.memberId.toString(),
155+
amount: Math.round(
156+
(challengeReviewer.basePayment ?? 0) +
157+
(challengeReviewer.incrementalPayment ?? 0) *
158+
challenge.numOfSubmissions *
159+
firstPlacePrize,
160+
),
161+
type: WinningsCategory.REVIEW_BOARD_PAYMENT,
162+
});
163+
});
164+
}
165+
166+
const totalAmount = payments.reduce(
167+
(sum, payment) => sum + payment.amount,
168+
0,
169+
);
170+
return payments.map((payment) => ({
171+
winnerId: payment.userId.toString(),
172+
type: WinningsType.PAYMENT,
173+
origin: 'Topcoder',
174+
category: payment.type,
175+
title: challenge.name,
176+
description: payment.description || challenge.name,
177+
externalId: challenge.id,
178+
details: [
179+
{
180+
totalAmount: payment.amount,
181+
grossAmount: payment.amount,
182+
installmentNumber: 1,
183+
currency: 'USD',
184+
billingAccount: `${challenge.billing.billingAccountId}`,
185+
challengeFee: totalAmount * challenge.billing.markup,
186+
},
187+
],
188+
attributes: {
189+
billingAccountId: challenge.billing.billingAccountId,
190+
payroll: includes(
191+
TGBillingAccounts,
192+
parseInt(challenge.billing.billingAccountId),
193+
),
194+
},
195+
}));
196+
}
197+
198+
async generateChallengePayments(challengeId: string, userId: string) {
199+
const challenge = await this.getChallenge(challengeId);
200+
201+
if (!challenge) {
202+
throw new Error('Challenge not found!');
203+
}
204+
205+
if (
206+
challenge.status.toLowerCase() !==
207+
ChallengeStatuses.Completed.toLowerCase()
208+
) {
209+
throw new Error("Challenge isn't completed yet!");
210+
}
211+
212+
const existingPayments = (
213+
await this.winningsRepo.searchWinnings({
214+
externalIds: [challengeId],
215+
limit: 1,
216+
offset: 0,
217+
} as WinningRequestDto)
218+
)?.data?.winnings;
219+
if (existingPayments?.length > 0) {
220+
this.logger.log(
221+
`Payments already exist for challenge ${challengeId}, skipping payment generation`,
222+
);
223+
throw new Error(
224+
`Payments already exist for challenge ${challengeId}, skipping payment generation`,
225+
);
226+
}
227+
228+
const payments = await this.getChallengePayments(challenge);
229+
const totalAmount = payments.reduce(
230+
(sum, payment) => sum + payment.details[0].totalAmount,
231+
0,
232+
);
233+
234+
const baValidation = {
235+
challengeId: challenge.id,
236+
billingAccountId: +challenge.billing.billingAccountId,
237+
markup: challenge.billing.markup,
238+
status: challenge.status,
239+
totalPrizesInCents: totalAmount * 100,
240+
};
241+
242+
if (challenge.billing?.clientBillingRate != null) {
243+
baValidation.markup = challenge.billing.clientBillingRate;
244+
}
245+
246+
await Promise.all(
247+
payments.map((p) =>
248+
this.winningsService.createWinningWithPayments(p, userId),
249+
),
250+
);
251+
252+
this.logger.log('Task Completed. locking consumed budget', baValidation);
253+
await this.baService.lockConsumeAmount(baValidation);
254+
}
255+
}

0 commit comments

Comments
 (0)