Skip to content

Commit 4f6576a

Browse files
committed
Fix budget approver flow
1 parent 8cadd3e commit 4f6576a

4 files changed

Lines changed: 292 additions & 37 deletions

File tree

src/api/admin/admin.service.spec.ts

Lines changed: 118 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ describe('AdminService', () => {
8787
getHandlesByUserIds: jest.Mock;
8888
};
8989
let topcoderChallengesService: {
90+
findChallengeByProjectAndTitle: jest.Mock;
91+
findChallengeIdFromProjectPhases: jest.Mock;
9092
getChallengeById: jest.Mock;
9193
getProjectById: jest.Mock;
9294
};
@@ -136,6 +138,8 @@ describe('AdminService', () => {
136138
}),
137139
};
138140
topcoderChallengesService = {
141+
findChallengeByProjectAndTitle: jest.fn().mockResolvedValue(undefined),
142+
findChallengeIdFromProjectPhases: jest.fn().mockResolvedValue(undefined),
139143
getChallengeById: jest.fn().mockResolvedValue(undefined),
140144
getProjectById: jest.fn().mockResolvedValue(undefined),
141145
};
@@ -151,30 +155,119 @@ describe('AdminService', () => {
151155
);
152156
});
153157

154-
it('returns engagement budgetApproverHandle from winnings attributes challengeId', async () => {
158+
it('returns engagement budgetApproverHandle from challenge approvalApprovedBy', async () => {
155159
prisma.winnings.findFirst.mockResolvedValue({
156160
winning_id: 'winning-1',
157161
category: 'ENGAGEMENT_PAYMENT',
158162
created_by: '654321',
159163
external_id: 'assignment-1',
160-
attributes: {
161-
challengeId: 'challenge-uuid-1',
162-
},
164+
attributes: {},
163165
});
164166
topcoderEngagementsService.getAssignmentContextById.mockResolvedValue({
165167
assignmentId: 'assignment-1',
168+
challengeId: 'da8b0e8b-f56a-419b-9e4e-b8afe06375e2',
166169
engagementId: 'engagement-1',
167-
engagementTitle: 'May 19 pvt eng',
170+
engagementTitle: 'Software Developer I',
168171
projectId: '100575',
169172
projectName: 'Ai Reviewer Wf testing',
170-
ratePerHour: '2.99',
171-
standardHoursPerWeek: 11,
172-
startDate: '2026-05-23T12:00:00.000Z',
173-
durationMonths: 2,
173+
ratePerHour: '15.5',
174+
standardHoursPerWeek: 12,
175+
startDate: '2026-04-30T12:00:00.000Z',
176+
durationMonths: 1,
177+
});
178+
topcoderChallengesService.getChallengeById.mockResolvedValue({
179+
id: 'da8b0e8b-f56a-419b-9e4e-b8afe06375e2',
180+
name: 'Software Developer I',
181+
projectId: 100575,
182+
approvalApprovedBy: 'kartik',
183+
});
184+
185+
const result = await service.getWinningPaymentDetails(
186+
'winning-1',
187+
'123456',
188+
['Payment Admin'],
189+
);
190+
191+
expect(topcoderChallengesService.getChallengeById).toHaveBeenCalledWith(
192+
'da8b0e8b-f56a-419b-9e4e-b8afe06375e2',
193+
);
194+
expect(
195+
topcoderChallengesService.findChallengeIdFromProjectPhases,
196+
).not.toHaveBeenCalled();
197+
expect(result.data?.engagementDetails?.budgetApproverHandle).toBe('kartik');
198+
});
199+
200+
it('returns engagement budgetApproverHandle via project phase challengeGuid', async () => {
201+
prisma.winnings.findFirst.mockResolvedValue({
202+
winning_id: 'winning-1',
203+
category: 'ENGAGEMENT_PAYMENT',
204+
created_by: '654321',
205+
external_id: 'assignment-1',
206+
attributes: {},
207+
});
208+
topcoderEngagementsService.getAssignmentContextById.mockResolvedValue({
209+
assignmentId: 'assignment-1',
210+
engagementId: 'engagement-1',
211+
engagementTitle: 'Software Developer I',
212+
projectId: '100575',
213+
ratePerHour: '15.5',
214+
standardHoursPerWeek: 12,
215+
startDate: '2026-04-30T12:00:00.000Z',
216+
durationMonths: 1,
217+
});
218+
topcoderChallengesService.findChallengeIdFromProjectPhases.mockResolvedValue(
219+
'da8b0e8b-f56a-419b-9e4e-b8afe06375e2',
220+
);
221+
topcoderChallengesService.getChallengeById.mockResolvedValue({
222+
id: 'da8b0e8b-f56a-419b-9e4e-b8afe06375e2',
223+
name: 'Software Developer I',
224+
projectId: 100575,
225+
approvalApprovedBy: 'kartik',
226+
});
227+
228+
const result = await service.getWinningPaymentDetails(
229+
'winning-1',
230+
'123456',
231+
['Payment Admin'],
232+
);
233+
234+
expect(
235+
topcoderChallengesService.findChallengeIdFromProjectPhases,
236+
).toHaveBeenCalledWith('100575', 'Software Developer I');
237+
expect(topcoderChallengesService.getChallengeById).toHaveBeenCalledWith(
238+
'da8b0e8b-f56a-419b-9e4e-b8afe06375e2',
239+
);
240+
expect(result.data?.engagementDetails?.budgetApproverHandle).toBe('kartik');
241+
});
242+
243+
it('returns engagement budgetApproverHandle via project and title search', async () => {
244+
prisma.winnings.findFirst.mockResolvedValue({
245+
winning_id: 'winning-1',
246+
category: 'ENGAGEMENT_PAYMENT',
247+
created_by: '654321',
248+
external_id: 'assignment-1',
249+
attributes: {},
250+
});
251+
topcoderEngagementsService.getAssignmentContextById.mockResolvedValue({
252+
assignmentId: 'assignment-1',
253+
engagementId: 'engagement-1',
254+
engagementTitle: 'Software Developer I',
255+
projectId: '100575',
256+
projectName: 'Ai Reviewer Wf testing',
257+
ratePerHour: '15.5',
258+
standardHoursPerWeek: 12,
259+
startDate: '2026-04-30T12:00:00.000Z',
260+
durationMonths: 1,
261+
});
262+
topcoderChallengesService.findChallengeByProjectAndTitle.mockResolvedValue({
263+
id: 'challenge-uuid-1',
264+
name: 'Software Developer I',
265+
projectId: 100575,
266+
approvalApprovedBy: 'kartik',
174267
});
175268
topcoderChallengesService.getChallengeById.mockResolvedValue({
176269
id: 'challenge-uuid-1',
177-
name: 'Engagement challenge',
270+
name: 'Software Developer I',
178271
projectId: 100575,
179272
approvalApprovedBy: 'kartik',
180273
});
@@ -185,40 +278,52 @@ describe('AdminService', () => {
185278
['Payment Admin'],
186279
);
187280

281+
expect(
282+
topcoderChallengesService.findChallengeByProjectAndTitle,
283+
).toHaveBeenCalledWith('100575', 'Software Developer I');
188284
expect(topcoderChallengesService.getChallengeById).toHaveBeenCalledWith(
189285
'challenge-uuid-1',
190286
);
191287
expect(result.data?.engagementDetails?.budgetApproverHandle).toBe('kartik');
192288
});
193289

194-
it('returns engagement budgetApproverHandle from winnings attributes directly', async () => {
290+
it('returns engagement budgetApproverHandle from winnings attributes challengeId', async () => {
195291
prisma.winnings.findFirst.mockResolvedValue({
196292
winning_id: 'winning-1',
197293
category: 'ENGAGEMENT_PAYMENT',
198294
created_by: '654321',
199295
external_id: 'assignment-1',
200296
attributes: {
201-
budgetApproverHandle: 'kartik',
297+
challengeId: 'challenge-uuid-1',
202298
},
203299
});
204300
topcoderEngagementsService.getAssignmentContextById.mockResolvedValue({
205301
assignmentId: 'assignment-1',
206302
engagementId: 'engagement-1',
207303
engagementTitle: 'May 19 pvt eng',
208304
projectId: '100575',
305+
projectName: 'Ai Reviewer Wf testing',
209306
ratePerHour: '2.99',
210307
standardHoursPerWeek: 11,
211308
startDate: '2026-05-23T12:00:00.000Z',
212309
durationMonths: 2,
213310
});
311+
topcoderChallengesService.getChallengeById.mockResolvedValue({
312+
id: 'challenge-uuid-1',
313+
name: 'Engagement challenge',
314+
projectId: 100575,
315+
approvalApprovedBy: 'kartik',
316+
});
214317

215318
const result = await service.getWinningPaymentDetails(
216319
'winning-1',
217320
'123456',
218321
['Payment Admin'],
219322
);
220323

221-
expect(topcoderChallengesService.getChallengeById).not.toHaveBeenCalled();
324+
expect(topcoderChallengesService.getChallengeById).toHaveBeenCalledWith(
325+
'challenge-uuid-1',
326+
);
222327
expect(result.data?.engagementDetails?.budgetApproverHandle).toBe('kartik');
223328
});
224329

src/api/admin/admin.service.ts

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,8 @@ export class AdminService {
149149
return undefined;
150150
}
151151

152-
const challengeId = (winning.attributes as Record<string, unknown>)
153-
.challengeId;
152+
const attributes = winning.attributes as Record<string, unknown>;
153+
const challengeId = attributes.challengeId ?? attributes.challengeGuid;
154154

155155
if (typeof challengeId === 'string') {
156156
const normalizedChallengeId = challengeId.trim();
@@ -1215,28 +1215,43 @@ export class AdminService {
12151215
}
12161216

12171217
/**
1218-
* Resolves the payment approver handle from the audit entry that moved the
1219-
* winning from `ON_HOLD_ADMIN` to `OWED`.
1220-
*/
1221-
/**
1222-
* Resolves the engagement budget approver from data stored on the winning row.
1223-
* Uses `attributes.budgetApproverHandle` when present, otherwise looks up the
1224-
* linked challenge via `attributes.challengeId` or assignment context.
1218+
* Resolves the engagement budget approver from the linked challenge's
1219+
* `approvalApprovedBy` field (`GET /v6/challenges/{challengeId}`).
12251220
*/
12261221
private async resolveEngagementBudgetApproverHandle(
12271222
winning: Awaited<ReturnType<AdminService['getWinningById']>>,
1228-
assignmentContext?: { challengeId?: string | null },
1223+
engagementLookup?: {
1224+
challengeId?: string | null;
1225+
engagementTitle?: string;
1226+
projectId?: string;
1227+
},
12291228
): Promise<string | undefined> {
1230-
const attributeBudgetApprover = this.getStringAttribute(
1231-
winning?.attributes ?? null,
1232-
'budgetApproverHandle',
1233-
);
1229+
let challengeId = this.getWinningChallengeId(winning, engagementLookup);
12341230

1235-
if (attributeBudgetApprover) {
1236-
return this.getPaymentCreatorHandle(attributeBudgetApprover);
1231+
if (
1232+
!challengeId &&
1233+
engagementLookup?.projectId &&
1234+
engagementLookup?.engagementTitle
1235+
) {
1236+
challengeId =
1237+
await this.topcoderChallengesService.findChallengeIdFromProjectPhases(
1238+
engagementLookup.projectId,
1239+
engagementLookup.engagementTitle,
1240+
);
12371241
}
12381242

1239-
const challengeId = this.getWinningChallengeId(winning, assignmentContext);
1243+
if (
1244+
!challengeId &&
1245+
engagementLookup?.projectId &&
1246+
engagementLookup?.engagementTitle
1247+
) {
1248+
const challenge =
1249+
await this.topcoderChallengesService.findChallengeByProjectAndTitle(
1250+
engagementLookup.projectId,
1251+
engagementLookup.engagementTitle,
1252+
);
1253+
challengeId = challenge?.id;
1254+
}
12401255

12411256
if (!challengeId) {
12421257
return undefined;
@@ -1256,6 +1271,11 @@ export class AdminService {
12561271
}
12571272
}
12581273

1274+
/**
1275+
* Resolves the payment approver handle from the audit entry that moved the
1276+
* winning from `ON_HOLD_ADMIN` to `OWED`.
1277+
*/
1278+
12591279
private async resolvePaymentApproverHandleFromAudit(
12601280
winningsId: string,
12611281
): Promise<string | undefined> {
@@ -1427,15 +1447,19 @@ export class AdminService {
14271447
assignmentId,
14281448
);
14291449

1450+
const builtEngagementDetails = this.buildEngagementDetailsFromEngagement(
1451+
engagement,
1452+
assignment,
1453+
assignmentId,
1454+
);
14301455
const budgetApproverHandle =
1431-
await this.resolveEngagementBudgetApproverHandle(winning);
1456+
await this.resolveEngagementBudgetApproverHandle(winning, {
1457+
engagementTitle: builtEngagementDetails.engagementTitle,
1458+
projectId: builtEngagementDetails.projectId,
1459+
});
14321460

14331461
result.data.engagementDetails = {
1434-
...this.buildEngagementDetailsFromEngagement(
1435-
engagement,
1436-
assignment,
1437-
assignmentId,
1438-
),
1462+
...builtEngagementDetails,
14391463
paymentApproverHandle,
14401464
budgetApproverHandle,
14411465
};

src/api/admin/dto/payment-details.dto.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export class PaymentEngagementDetailsDto {
7070

7171
@ApiPropertyOptional({
7272
description:
73-
'The Topcoder handle of the user who approved the engagement budget (from winnings attributes or linked challenge)',
73+
'The Topcoder handle of the user who approved the engagement budget (from challenge `approvalApprovedBy`)',
7474
example: 'budget_approver_handle',
7575
})
7676
budgetApproverHandle?: string;

0 commit comments

Comments
 (0)