Skip to content

Commit 79c40a2

Browse files
committed
Merge branch 'dev' of github.com:topcoder-platform/tc-finance-api into PM-4789_payment-migrations
2 parents 3e2e885 + b9610b3 commit 79c40a2

3 files changed

Lines changed: 341 additions & 27 deletions

File tree

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe('AdminService', () => {
5252
};
5353
let topcoderEngagementsService: {
5454
getAssignmentContextById: jest.Mock;
55+
getEngagementById: jest.Mock;
5556
};
5657

5758
beforeEach(() => {
@@ -67,6 +68,7 @@ describe('AdminService', () => {
6768
};
6869
topcoderEngagementsService = {
6970
getAssignmentContextById: jest.fn(),
71+
getEngagementById: jest.fn(),
7072
};
7173

7274
service = new AdminService(
@@ -163,6 +165,105 @@ describe('AdminService', () => {
163165
});
164166
});
165167

168+
it('falls back to the engagement lookup when external_id stores an engagement id', async () => {
169+
prisma.winnings.findFirst.mockResolvedValue({
170+
winning_id: 'winning-1',
171+
winner_id: '123456',
172+
category: 'ENGAGEMENT_PAYMENT',
173+
external_id: 'engagement-1',
174+
attributes: {
175+
hoursWorked: 10,
176+
remarks: 'Covered support hours.',
177+
},
178+
});
179+
topcoderEngagementsService.getAssignmentContextById.mockRejectedValue(
180+
new Error('assignment not found'),
181+
);
182+
topcoderEngagementsService.getEngagementById.mockResolvedValue({
183+
assignments: [
184+
{
185+
durationMonths: 6,
186+
id: 'assignment-1',
187+
memberId: '123456',
188+
otherRemarks: 'Working EST overlap.',
189+
ratePerHour: '82.50',
190+
standardHoursPerWeek: 35,
191+
startDate: '2026-01-15T00:00:00.000Z',
192+
},
193+
],
194+
id: 'engagement-1',
195+
projectId: 'project-1',
196+
projectName: 'Platform Modernization',
197+
title: 'Senior Frontend Engineer',
198+
});
199+
200+
const result = await service.getWinningPaymentDetails(
201+
'winning-1',
202+
'123456',
203+
['Payment Admin'],
204+
);
205+
206+
expect(
207+
topcoderEngagementsService.getAssignmentContextById,
208+
).toHaveBeenCalledWith('engagement-1');
209+
expect(topcoderEngagementsService.getEngagementById).toHaveBeenCalledWith(
210+
'engagement-1',
211+
);
212+
expect(result.data).toEqual({
213+
engagementDetails: {
214+
assignmentId: 'assignment-1',
215+
billingStartDate: new Date('2026-01-15T00:00:00.000Z'),
216+
durationMonths: 6,
217+
engagementId: 'engagement-1',
218+
engagementTitle: 'Senior Frontend Engineer',
219+
otherRemarks: 'Working EST overlap.',
220+
projectId: 'project-1',
221+
projectName: 'Platform Modernization',
222+
ratePerHour: '82.50',
223+
standardHoursPerWeek: 35,
224+
},
225+
workLog: {
226+
hoursWorked: 10,
227+
remarks: 'Covered support hours.',
228+
},
229+
});
230+
});
231+
232+
it('prefers a numeric assignmentId stored in winning attributes', async () => {
233+
prisma.winnings.findFirst.mockResolvedValue({
234+
winning_id: 'winning-1',
235+
category: 'ENGAGEMENT_PAYMENT',
236+
external_id: 'engagement-1',
237+
attributes: {
238+
assignmentId: 98765,
239+
hoursWorked: 12,
240+
remarks: 'Weekly support work.',
241+
},
242+
});
243+
topcoderEngagementsService.getAssignmentContextById.mockResolvedValue({
244+
assignmentId: '98765',
245+
engagementId: 'engagement-1',
246+
engagementTitle: 'Senior Frontend Engineer',
247+
projectId: 'project-1',
248+
projectName: 'Platform Modernization',
249+
ratePerHour: '75.50',
250+
standardHoursPerWeek: 40,
251+
startDate: '2026-02-12T00:00:00.000Z',
252+
status: 'ACTIVE',
253+
memberHandle: 'tester',
254+
memberId: '123456',
255+
});
256+
257+
await service.getWinningPaymentDetails('winning-1', '123456', [
258+
'Payment Admin',
259+
]);
260+
261+
expect(
262+
topcoderEngagementsService.getAssignmentContextById,
263+
).toHaveBeenCalledWith('98765');
264+
expect(topcoderEngagementsService.getEngagementById).not.toHaveBeenCalled();
265+
});
266+
166267
it('throws when the winning does not exist', async () => {
167268
prisma.winnings.findFirst.mockResolvedValue(null);
168269

src/api/admin/admin.service.ts

Lines changed: 187 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import { WinningAuditDto, AuditPayoutDto } from './dto/audit.dto';
1717
import { WinningUpdateRequestDto } from './dto/winnings.dto';
1818
import { Logger } from 'src/shared/global';
1919
import { BillingAccountsService } from 'src/shared/topcoder/billing-accounts.service';
20-
import { TopcoderEngagementsService } from 'src/shared/topcoder/engagements.service';
20+
import {
21+
TopcoderEngagementAssignment,
22+
TopcoderEngagementDetails,
23+
TopcoderEngagementsService,
24+
} from 'src/shared/topcoder/engagements.service';
2125
import { WinningPaymentDetailsDto } from './dto/payment-details.dto';
2226

2327
/**
@@ -100,11 +104,133 @@ export class AdminService {
100104
private getWinningAssignmentId(
101105
winning: Awaited<ReturnType<AdminService['getWinningById']>>,
102106
): string | undefined {
103-
return (
104-
this.getStringAttribute(winning?.attributes ?? null, 'assignmentId') ??
105-
winning?.external_id ??
106-
undefined
107-
);
107+
if (
108+
!winning?.attributes ||
109+
typeof winning.attributes !== 'object' ||
110+
Array.isArray(winning.attributes)
111+
) {
112+
return undefined;
113+
}
114+
115+
const assignmentId = (winning.attributes as Record<string, unknown>)
116+
.assignmentId;
117+
118+
if (typeof assignmentId === 'string') {
119+
const normalizedAssignmentId = assignmentId.trim();
120+
return normalizedAssignmentId || undefined;
121+
}
122+
123+
if (typeof assignmentId === 'number' && Number.isFinite(assignmentId)) {
124+
return String(assignmentId);
125+
}
126+
127+
return undefined;
128+
}
129+
130+
/**
131+
* Finds the engagement assignment that best matches the current winning.
132+
*
133+
* @param assignments assignments returned by the engagements API.
134+
* @param winnerId Topcoder member identifier stored on the winning.
135+
* @param assignmentId optional assignment identifier captured on the winning.
136+
* @returns the matched assignment, or the only assignment when the engagement
137+
* has a single assignee.
138+
* @throws This helper does not throw.
139+
*/
140+
private findMatchingEngagementAssignment(
141+
assignments: TopcoderEngagementAssignment[] | null | undefined,
142+
winnerId: string,
143+
assignmentId?: string,
144+
): TopcoderEngagementAssignment | undefined {
145+
if (!Array.isArray(assignments) || assignments.length === 0) {
146+
return undefined;
147+
}
148+
149+
if (assignmentId) {
150+
const assignmentMatch = assignments.find((item) => {
151+
if (item.id === undefined || item.id === null) {
152+
return false;
153+
}
154+
155+
return String(item.id).trim() === assignmentId;
156+
});
157+
158+
if (assignmentMatch) {
159+
return assignmentMatch;
160+
}
161+
}
162+
163+
const winnerMatch = assignments.find((item) => {
164+
if (item.memberId === undefined || item.memberId === null) {
165+
return false;
166+
}
167+
168+
return String(item.memberId).trim() === winnerId;
169+
});
170+
171+
if (winnerMatch) {
172+
return winnerMatch;
173+
}
174+
175+
return assignments.length === 1 ? assignments[0] : undefined;
176+
}
177+
178+
/**
179+
* Builds wallet-admin engagement details from an engagement record.
180+
*
181+
* @param engagement engagement payload returned by the engagements API.
182+
* @param assignment matched assignment for the winning, when available.
183+
* @param assignmentId optional assignment identifier captured on the winning.
184+
* @returns engagement details shaped for the payment details response.
185+
* @throws This helper does not throw.
186+
*/
187+
private buildEngagementDetailsFromEngagement(
188+
engagement: TopcoderEngagementDetails,
189+
assignment?: TopcoderEngagementAssignment,
190+
assignmentId?: string,
191+
): WinningPaymentDetailsDto['engagementDetails'] {
192+
const durationMonths =
193+
assignment?.durationMonths !== undefined &&
194+
assignment.durationMonths !== null
195+
? Number(assignment.durationMonths)
196+
: undefined;
197+
const standardHoursPerWeek =
198+
assignment?.standardHoursPerWeek !== undefined &&
199+
assignment.standardHoursPerWeek !== null
200+
? Number(assignment.standardHoursPerWeek)
201+
: undefined;
202+
const projectId = engagement.projectId ?? engagement.project?.id;
203+
const projectName =
204+
(engagement.projectName ?? engagement.project?.name)?.trim() ?? undefined;
205+
const engagementId =
206+
engagement.id !== undefined && engagement.id !== null
207+
? String(engagement.id).trim() || undefined
208+
: undefined;
209+
210+
return {
211+
assignmentId:
212+
assignment?.id !== undefined && assignment.id !== null
213+
? String(assignment.id).trim() || assignmentId
214+
: assignmentId,
215+
engagementId,
216+
projectId:
217+
projectId !== undefined && projectId !== null
218+
? String(projectId).trim() || undefined
219+
: undefined,
220+
projectName,
221+
engagementTitle: engagement.title?.trim() ?? undefined,
222+
billingStartDate: assignment?.startDate
223+
? new Date(assignment.startDate)
224+
: undefined,
225+
durationMonths: Number.isFinite(durationMonths)
226+
? durationMonths
227+
: undefined,
228+
ratePerHour: assignment?.ratePerHour?.trim() ?? undefined,
229+
standardHoursPerWeek: Number.isFinite(standardHoursPerWeek)
230+
? standardHoursPerWeek
231+
: undefined,
232+
otherRemarks: assignment?.otherRemarks?.trim() ?? undefined,
233+
};
108234
}
109235

110236
private getPaymentsByWinningsId(winningsId: string, paymentId?: string) {
@@ -572,36 +698,70 @@ export class AdminService {
572698
};
573699

574700
const assignmentId = this.getWinningAssignmentId(winning);
701+
const externalId =
702+
typeof winning.external_id === 'string'
703+
? winning.external_id.trim() || undefined
704+
: undefined;
705+
const assignmentLookupId = assignmentId ?? externalId;
575706
const isEngagementPayment = winning.category === 'ENGAGEMENT_PAYMENT';
576707

577-
if (!isEngagementPayment || !assignmentId) {
708+
if (!isEngagementPayment) {
578709
return result;
579710
}
580711

581-
try {
582-
const assignmentContext =
583-
await this.topcoderEngagementsService.getAssignmentContextById(
584-
assignmentId,
712+
if (assignmentLookupId) {
713+
try {
714+
const assignmentContext =
715+
await this.topcoderEngagementsService.getAssignmentContextById(
716+
assignmentLookupId,
717+
);
718+
719+
result.data.engagementDetails = {
720+
assignmentId: assignmentContext.assignmentId,
721+
engagementId: assignmentContext.engagementId,
722+
projectId: assignmentContext.projectId,
723+
projectName: assignmentContext.projectName ?? undefined,
724+
engagementTitle: assignmentContext.engagementTitle,
725+
billingStartDate: assignmentContext.startDate
726+
? new Date(assignmentContext.startDate)
727+
: undefined,
728+
durationMonths: assignmentContext.durationMonths ?? undefined,
729+
ratePerHour: assignmentContext.ratePerHour ?? undefined,
730+
standardHoursPerWeek:
731+
assignmentContext.standardHoursPerWeek ?? undefined,
732+
otherRemarks: assignmentContext.otherRemarks ?? undefined,
733+
};
734+
735+
return result;
736+
} catch (error) {
737+
this.logger.warn(
738+
`Failed to enrich winning ${winningsId} with assignment context`,
739+
error instanceof Error ? error.message : error,
585740
);
741+
}
742+
}
586743

587-
result.data.engagementDetails = {
588-
assignmentId: assignmentContext.assignmentId,
589-
engagementId: assignmentContext.engagementId,
590-
projectId: assignmentContext.projectId,
591-
projectName: assignmentContext.projectName ?? undefined,
592-
engagementTitle: assignmentContext.engagementTitle,
593-
billingStartDate: assignmentContext.startDate
594-
? new Date(assignmentContext.startDate)
595-
: undefined,
596-
durationMonths: assignmentContext.durationMonths ?? undefined,
597-
ratePerHour: assignmentContext.ratePerHour ?? undefined,
598-
standardHoursPerWeek:
599-
assignmentContext.standardHoursPerWeek ?? undefined,
600-
otherRemarks: assignmentContext.otherRemarks ?? undefined,
601-
};
744+
if (!externalId) {
745+
return result;
746+
}
747+
748+
try {
749+
const engagement =
750+
await this.topcoderEngagementsService.getEngagementById(externalId);
751+
const assignment = this.findMatchingEngagementAssignment(
752+
engagement.assignments,
753+
winning.winner_id,
754+
assignmentId,
755+
);
756+
757+
result.data.engagementDetails = this.buildEngagementDetailsFromEngagement(
758+
engagement,
759+
assignment,
760+
assignmentId,
761+
);
602762
} catch (error) {
603763
this.logger.warn(
604-
`Failed to enrich winning ${winningsId} with engagement context`,
764+
`Failed to enrich winning ${winningsId} with engagement details`,
605765
error instanceof Error ? error.message : error,
606766
);
607767
}

0 commit comments

Comments
 (0)