Skip to content

Commit 1f3da0c

Browse files
authored
Merge branch 'dev' into PM-5043
2 parents 2c5bcdf + 7488b19 commit 1f3da0c

13 files changed

Lines changed: 512 additions & 62 deletions

.circleci/config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ workflows:
7676
- dev
7777
- engagements
7878
- PM-4789_payment-migrations
79+
- PM-5149
7980
- 'build-prod':
8081
context: org-global
8182
filters:

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,48 @@ describe('AdminService', () => {
151151
);
152152
});
153153

154+
it('returns paymentApproverHandle for engagement payments from audit trail', async () => {
155+
prisma.winnings.findFirst.mockResolvedValue({
156+
winning_id: 'winning-1',
157+
category: 'ENGAGEMENT_PAYMENT',
158+
created_by: '654321',
159+
external_id: 'assignment-1',
160+
attributes: {},
161+
});
162+
topcoderEngagementsService.getAssignmentContextById.mockResolvedValue({
163+
assignmentId: 'assignment-1',
164+
engagementId: 'engagement-1',
165+
engagementTitle: 'May 19 pvt eng',
166+
projectId: '100575',
167+
projectName: 'Ai Reviewer Wf testing',
168+
ratePerHour: '2.99',
169+
standardHoursPerWeek: 11,
170+
startDate: '2026-05-23T12:00:00.000Z',
171+
durationMonths: 2,
172+
});
173+
prisma.audit.findMany.mockResolvedValue([
174+
{
175+
id: 'audit-1',
176+
winnings_id: 'winning-1',
177+
user_id: '654321',
178+
action: 'status updated from ON_HOLD_ADMIN to OWED',
179+
note: null,
180+
created_at: new Date(),
181+
},
182+
]);
183+
184+
const result = await service.getWinningPaymentDetails(
185+
'winning-1',
186+
'123456',
187+
['Payment Admin'],
188+
);
189+
190+
expect(result.data?.engagementDetails?.paymentApproverHandle).toBe(
191+
'payment-manager',
192+
);
193+
expect(result.data?.engagementDetails?.assignmentId).toBe('assignment-1');
194+
});
195+
154196
it('returns work-log and engagement details for engagement payments', async () => {
155197
prisma.winnings.findFirst.mockResolvedValue({
156198
winning_id: 'winning-1',
@@ -200,9 +242,12 @@ describe('AdminService', () => {
200242
engagementId: 'engagement-1',
201243
engagementTitle: 'Senior Frontend Engineer',
202244
otherRemarks: 'Complete onboarding within the first week.',
245+
paymentApproverHandle: undefined,
246+
paymentCycle: 'WEEKLY',
203247
projectId: 'project-1',
204248
projectName: 'Platform Modernization',
205249
ratePerHour: '75.50',
250+
standardHoursPerDay: 8,
206251
standardHoursPerWeek: 40,
207252
},
208253
paymentCreatorHandle: 'payment-manager',
@@ -296,9 +341,12 @@ describe('AdminService', () => {
296341
engagementId: 'engagement-1',
297342
engagementTitle: 'Senior Frontend Engineer',
298343
otherRemarks: 'Working EST overlap.',
344+
paymentApproverHandle: undefined,
345+
paymentCycle: 'WEEKLY',
299346
projectId: 'project-1',
300347
projectName: 'Platform Modernization',
301348
ratePerHour: '82.50',
349+
standardHoursPerDay: 7,
302350
standardHoursPerWeek: 35,
303351
},
304352
paymentCreatorHandle: 'payment-manager',

src/api/admin/admin.service.ts

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@ import {
3333
TopcoderChallengeInfo,
3434
TopcoderChallengesService,
3535
} from 'src/shared/topcoder/challenges.service';
36-
import { WinningPaymentDetailsDto } from './dto/payment-details.dto';
3736
import { resolveChallengeMemberPaymentAmount } from 'src/shared/payments/challenge-payment-amount.util';
37+
import {
38+
PaymentCycle,
39+
WinningPaymentDetailsDto,
40+
} from './dto/payment-details.dto';
3841

3942
const PAYMENT_DECIMAL_PLACES = 2;
4043
const BUDGET_LEDGER_DECIMAL_PLACES = 4;
@@ -262,6 +265,13 @@ export class AdminService {
262265
assignment.standardHoursPerWeek !== null
263266
? Number(assignment.standardHoursPerWeek)
264267
: undefined;
268+
const standardHoursPerDay =
269+
assignment?.standardHoursPerDay !== undefined &&
270+
assignment.standardHoursPerDay !== null
271+
? Number(assignment.standardHoursPerDay)
272+
: Number.isFinite(standardHoursPerWeek)
273+
? Number(((standardHoursPerWeek ?? 0) / 5).toFixed(2))
274+
: undefined;
265275
const projectId = engagement.projectId ?? engagement.project?.id;
266276
const projectName =
267277
(engagement.projectName ?? engagement.project?.name)?.trim() ?? undefined;
@@ -289,6 +299,11 @@ export class AdminService {
289299
? durationMonths
290300
: undefined,
291301
ratePerHour: assignment?.ratePerHour?.trim() ?? undefined,
302+
paymentCycle: (assignment?.paymentCycle?.trim() ||
303+
'WEEKLY') as PaymentCycle,
304+
standardHoursPerDay: Number.isFinite(standardHoursPerDay)
305+
? standardHoursPerDay
306+
: undefined,
292307
standardHoursPerWeek: Number.isFinite(standardHoursPerWeek)
293308
? standardHoursPerWeek
294309
: undefined,
@@ -1187,6 +1202,38 @@ export class AdminService {
11871202
return result;
11881203
}
11891204

1205+
/**
1206+
* Resolves the payment approver handle from the audit entry that moved the
1207+
* winning from `ON_HOLD_ADMIN` to `OWED`.
1208+
*/
1209+
private async resolvePaymentApproverHandleFromAudit(
1210+
winningsId: string,
1211+
): Promise<string | undefined> {
1212+
try {
1213+
const audits = await this.prisma.audit.findMany({
1214+
where: { winnings_id: winningsId },
1215+
orderBy: { created_at: 'desc' },
1216+
take: 200,
1217+
});
1218+
const approverAudit = audits.find(
1219+
(audit) =>
1220+
typeof audit.action === 'string' &&
1221+
audit.action.includes('ON_HOLD_ADMIN') &&
1222+
audit.action.includes('OWED') &&
1223+
audit.action.indexOf('ON_HOLD_ADMIN') < audit.action.indexOf('OWED'),
1224+
);
1225+
1226+
if (!approverAudit?.user_id) {
1227+
return undefined;
1228+
}
1229+
1230+
return this.getPaymentCreatorHandle(String(approverAudit.user_id));
1231+
} catch {
1232+
// approver is optional — ignore failures
1233+
return undefined;
1234+
}
1235+
}
1236+
11901237
private async buildTaskDetails(
11911238
winningsId: string,
11921239
externalId: string | undefined,
@@ -1220,27 +1267,8 @@ export class AdminService {
12201267
}
12211268
}
12221269

1223-
try {
1224-
const audits = await this.prisma.audit.findMany({
1225-
where: { winnings_id: winningsId },
1226-
orderBy: { created_at: 'desc' },
1227-
take: 200,
1228-
});
1229-
const approverAudit = audits.find(
1230-
(a) =>
1231-
typeof a.action === 'string' &&
1232-
a.action.includes('ON_HOLD_ADMIN') &&
1233-
a.action.includes('OWED') &&
1234-
a.action.indexOf('ON_HOLD_ADMIN') < a.action.indexOf('OWED'),
1235-
);
1236-
if (approverAudit?.user_id) {
1237-
const userId = String(approverAudit.user_id);
1238-
const handle = await this.getPaymentCreatorHandle(userId);
1239-
taskDetails.paymentApproverHandle = handle ?? undefined;
1240-
}
1241-
} catch {
1242-
// approver is optional — ignore failures
1243-
}
1270+
taskDetails.paymentApproverHandle =
1271+
await this.resolvePaymentApproverHandleFromAudit(winningsId);
12441272

12451273
return taskDetails;
12461274
}
@@ -1294,13 +1322,15 @@ export class AdminService {
12941322
return result;
12951323
}
12961324

1325+
const paymentApproverHandle =
1326+
await this.resolvePaymentApproverHandleFromAudit(winningsId);
1327+
12971328
if (assignmentLookupId) {
12981329
try {
12991330
const assignmentContext =
13001331
await this.topcoderEngagementsService.getAssignmentContextById(
13011332
assignmentLookupId,
13021333
);
1303-
13041334
result.data.engagementDetails = {
13051335
assignmentId: assignmentContext.assignmentId,
13061336
engagementId: assignmentContext.engagementId,
@@ -1312,9 +1342,18 @@ export class AdminService {
13121342
: undefined,
13131343
durationMonths: assignmentContext.durationMonths ?? undefined,
13141344
ratePerHour: assignmentContext.ratePerHour ?? undefined,
1345+
paymentCycle: (assignmentContext.paymentCycle ??
1346+
'WEEKLY') as PaymentCycle,
1347+
standardHoursPerDay:
1348+
assignmentContext.standardHoursPerDay ??
1349+
(assignmentContext.standardHoursPerWeek !== undefined &&
1350+
assignmentContext.standardHoursPerWeek !== null
1351+
? Number((assignmentContext.standardHoursPerWeek / 5).toFixed(2))
1352+
: undefined),
13151353
standardHoursPerWeek:
13161354
assignmentContext.standardHoursPerWeek ?? undefined,
13171355
otherRemarks: assignmentContext.otherRemarks ?? undefined,
1356+
paymentApproverHandle,
13181357
};
13191358

13201359
return result;
@@ -1339,11 +1378,14 @@ export class AdminService {
13391378
assignmentId,
13401379
);
13411380

1342-
result.data.engagementDetails = this.buildEngagementDetailsFromEngagement(
1343-
engagement,
1344-
assignment,
1345-
assignmentId,
1346-
);
1381+
result.data.engagementDetails = {
1382+
...this.buildEngagementDetailsFromEngagement(
1383+
engagement,
1384+
assignment,
1385+
assignmentId,
1386+
),
1387+
paymentApproverHandle,
1388+
};
13471389
} catch (error) {
13481390
this.logger.warn(
13491391
`Failed to enrich winning ${winningsId} with engagement details`,

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { ApiPropertyOptional } from '@nestjs/swagger';
22

3+
export type PaymentCycle = 'WEEKLY' | 'FORTNIGHTLY' | 'MONTHLY';
4+
35
export class PaymentEngagementDetailsDto {
46
@ApiPropertyOptional({
57
description: 'The assignment ID associated with the payment',
@@ -49,6 +51,19 @@ export class PaymentEngagementDetailsDto {
4951
})
5052
ratePerHour?: string;
5153

54+
@ApiPropertyOptional({
55+
description: 'Assignment payment cycle',
56+
enum: ['WEEKLY', 'FORTNIGHTLY', 'MONTHLY'],
57+
example: 'WEEKLY',
58+
})
59+
paymentCycle?: PaymentCycle;
60+
61+
@ApiPropertyOptional({
62+
description: 'Assignment standard hours per day',
63+
example: 8,
64+
})
65+
standardHoursPerDay?: number;
66+
5267
@ApiPropertyOptional({
5368
description: 'Assignment standard hours per week',
5469
example: 40,
@@ -60,6 +75,13 @@ export class PaymentEngagementDetailsDto {
6075
example: 'Complete onboarding within the first week.',
6176
})
6277
otherRemarks?: string;
78+
79+
@ApiPropertyOptional({
80+
description:
81+
'The Topcoder handle of the wallet-admin user who approved this payment',
82+
example: 'payment_approver_handle',
83+
})
84+
paymentApproverHandle?: string;
6385
}
6486

6587
export class PaymentWorkLogDto {

src/api/challenges/challenges.service.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,53 @@ describe('ChallengesService', () => {
260260
});
261261
});
262262

263+
it.each([
264+
['regular', undefined],
265+
['TAAS', [{ name: 'payment_type', value: 'taas' }]],
266+
['Topgear', [{ name: 'payment_type', value: 'topgear' }]],
267+
])(
268+
'defaults %s task copilot payments to OWED status',
269+
async (_, metadata) => {
270+
const service = new ChallengesService(
271+
{} as any,
272+
{} as any,
273+
{} as any,
274+
{} as any,
275+
{} as any,
276+
);
277+
278+
jest.spyOn(service, 'getChallengeResources').mockResolvedValue({
279+
copilot: [{ memberHandle: 'copilot', memberId: '40158995' }],
280+
} as any);
281+
jest.spyOn(service, 'getChallengeReviews').mockResolvedValue([]);
282+
283+
const winnings = await service.getChallengePayments({
284+
id: '11111111-1111-1111-1111-111111111111',
285+
name: 'Task Challenge',
286+
type: 'Task',
287+
task: { isTask: true },
288+
billing: { billingAccountId: '1234', markup: 0.2 },
289+
metadata,
290+
winners: [],
291+
checkpointWinners: [],
292+
reviewers: [],
293+
phases: [],
294+
prizeSets: [
295+
{ type: 'PLACEMENT', prizes: [{ type: PrizeType.USD, value: 500 }] },
296+
{ type: 'COPILOT', prizes: [{ type: PrizeType.USD, value: 100 }] },
297+
],
298+
} as any);
299+
300+
expect(winnings).toEqual([
301+
expect.objectContaining({
302+
category: WinningsCategory.COPILOT_PAYMENT,
303+
status: PaymentStatus.OWED,
304+
winnerId: '40158995',
305+
}),
306+
]);
307+
},
308+
);
309+
263310
it('does not force ON_HOLD_ADMIN status for non-task challenge winnings', async () => {
264311
const service = new ChallengesService(
265312
{} as any,

src/api/challenges/challenges.service.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ const PAYMENT_TYPE_TO_CATEGORY: Record<string, WinningsCategory> = {
6363
taas: WinningsCategory.TAAS_PAYMENT,
6464
topgear: WinningsCategory.TOPGEAR_PAYMENT,
6565
};
66+
const OWED_TASK_PAYMENT_CATEGORIES = new Set<WinningsCategory>([
67+
WinningsCategory.TAAS_PAYMENT,
68+
WinningsCategory.COPILOT_PAYMENT,
69+
WinningsCategory.TASK_COPILOT_PAYMENT,
70+
]);
6671

6772
const CANCELLED_CHALLENGE_STATUSES = [
6873
ChallengeStatuses.Canceled,
@@ -155,8 +160,9 @@ export class ChallengesService {
155160
* @param challenge Challenge details returned by challenge-api-v6.
156161
* @param category Winnings category selected for the generated payment.
157162
* @param currency Prize currency selected for the generated payment.
158-
* @returns OWED for USD TAAS task payments, ON_HOLD_ADMIN for other USD task
159-
* payments, and undefined when standard payout-readiness rules should apply.
163+
* @returns OWED for USD TAAS task payments and task copilot payments,
164+
* ON_HOLD_ADMIN for other USD task payments, and undefined when standard
165+
* payout-readiness rules should apply.
160166
* @throws This method does not throw.
161167
*/
162168
private getTaskPaymentStatus(
@@ -168,7 +174,7 @@ export class ChallengesService {
168174
return undefined;
169175
}
170176

171-
return category === WinningsCategory.TAAS_PAYMENT
177+
return OWED_TASK_PAYMENT_CATEGORIES.has(category)
172178
? PaymentStatus.OWED
173179
: PaymentStatus.ON_HOLD_ADMIN;
174180
}

0 commit comments

Comments
 (0)