Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
897f962
PM-4826 - make sure all task payments are kept on hold (admin)
vas3a Apr 23, 2026
06b7317
PM-4825 - task payment approver
vas3a Apr 24, 2026
c05e1a7
fix pr feedback
vas3a Apr 24, 2026
4bc10bc
Merge pull request #165 from topcoder-platform/PM-4826_tasks-to-on-ho…
vas3a Apr 24, 2026
5a1e551
Merge pull request #166 from topcoder-platform/PM-4825_task-payment-a…
vas3a Apr 24, 2026
20c901a
PM-4825 - show task payment details
vas3a Apr 24, 2026
38c07c8
fix check for approver audit
vas3a Apr 24, 2026
da1783d
fix test
vas3a Apr 24, 2026
cdd4aa5
PM-4830 - fix order
vas3a Apr 24, 2026
039790c
Merge pull request #167 from topcoder-platform/PM-4830_payment-modal-…
vas3a Apr 24, 2026
954de5a
Make sure consume value for engagement payments includes the challeng…
jmgasper Apr 26, 2026
e342dff
Merge branch 'dev' of github.com:topcoder-platform/tc-finance-api int…
jmgasper Apr 26, 2026
67bdb63
PM-4830 - put on hold payments for submitters & reviewers only
vas3a Apr 27, 2026
4d819cd
Make sure filters are applied for restricted categories
vas3a Apr 27, 2026
29c994f
Fix status for on hold admin
vas3a Apr 27, 2026
1f64d7d
Merge pull request #168 from topcoder-platform/PM-4830_payment-modal-…
vas3a Apr 27, 2026
02021dc
PM-4830 - task creator details
vas3a Apr 27, 2026
63e141a
Merge pull request #169 from topcoder-platform/PM-4830_payment-modal-…
vas3a Apr 27, 2026
6f3fdcf
PM-4951: Lock draft challenge payment budget
jmgasper Apr 28, 2026
bb731fb
Merge pull request #170 from topcoder-platform/PM-4951
jmgasper Apr 28, 2026
9fc2e4d
PM-4960: sync admin challenge payments to challenge BA
jmgasper Apr 28, 2026
a903bab
PM-4958: Sync BA budget after payment cancellation
jmgasper Apr 28, 2026
323ec72
PM-4957: Fix cancelled challenge budget sync
jmgasper Apr 28, 2026
8cb361f
Merge pull request #171 from topcoder-platform/PM-4960-1
jmgasper Apr 28, 2026
fa51d04
Merge pull request #172 from topcoder-platform/PM-4958-1
jmgasper Apr 28, 2026
fd0eff9
Merge pull request #173 from topcoder-platform/PM-4957-1
jmgasper Apr 28, 2026
9cad172
PM-4900: Backfill engagement BA consumed fees
jmgasper Apr 28, 2026
a32dddf
Merge pull request #174 from topcoder-platform/PM-4900
jmgasper Apr 28, 2026
e7419a8
PM-4958: Release engagement BA consumption on cancellation
jmgasper Apr 28, 2026
efd64ee
Merge pull request #175 from topcoder-platform/PM-4958-2
jmgasper Apr 28, 2026
1c1e7ec
Keep payment status
vas3a Apr 29, 2026
d86f537
Merge pull request #176 from topcoder-platform/fix-payment-status
vas3a Apr 29, 2026
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
22 changes: 11 additions & 11 deletions src/api/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ export class AdminController {
@Roles(
Role.PaymentAdmin,
Role.PaymentBaAdmin,
Role.EngagementPaymentApprover,
Role.PaymentApprover,
Role.WiproTaasAdmin,
Role.PaymentEditor,
Role.PaymentViewer,
)
@ApiOperation({
summary: 'Search winnings with parameters',
description:
'Roles: Payment Admin, Payment BA Admin, Engagement Payment Approver, Wipro TaaS Admin, Payment Editor, Payment Viewer',
'Roles: Payment Admin, Payment BA Admin, Payment Approver, Wipro TaaS Admin, Payment Editor, Payment Viewer',
})
@ApiBody({
description: 'Winning request body',
Expand Down Expand Up @@ -102,7 +102,7 @@ export class AdminController {
@Roles(
Role.PaymentAdmin,
Role.PaymentBaAdmin,
Role.EngagementPaymentApprover,
Role.PaymentApprover,
Role.WiproTaasAdmin,
Role.PaymentEditor,
Role.PaymentViewer,
Expand Down Expand Up @@ -145,15 +145,15 @@ export class AdminController {
@Roles(
Role.PaymentAdmin,
Role.PaymentBaAdmin,
Role.EngagementPaymentApprover,
Role.PaymentApprover,
Role.WiproTaasAdmin,
Role.PaymentEditor,
Role.PaymentViewer,
)
@ApiOperation({
summary: 'Export search winnings result in csv file format',
description:
'Roles: Payment Admin, Payment BA Admin, Engagement Payment Approver, Wipro TaaS Admin, Payment Editor, Payment Viewer. Engagement payment exports include the Payment Creator column.',
'Roles: Payment Admin, Payment BA Admin, Payment Approver, Wipro TaaS Admin, Payment Editor, Payment Viewer. Engagement payment exports include the Payment Creator column.',
})
@ApiBody({
description: 'Winning request body',
Expand Down Expand Up @@ -283,14 +283,14 @@ export class AdminController {
@Roles(
Role.PaymentAdmin,
Role.PaymentBaAdmin,
Role.EngagementPaymentApprover,
Role.PaymentApprover,
Role.WiproTaasAdmin,
Role.PaymentEditor,
)
@ApiOperation({
summary: 'Update winnings with given parameter',
description:
'Roles: Payment Admin, Payment BA Admin, Engagement Payment Approver, Wipro TaaS Admin, Payment Editor. paymentStatus, releaseDate and paymentAmount cannot be null at the same time.',
'Roles: Payment Admin, Payment BA Admin, Payment Approver, Wipro TaaS Admin, Payment Editor. paymentStatus, releaseDate and paymentAmount cannot be null at the same time.',
})
@ApiResponse({
status: 200,
Expand Down Expand Up @@ -330,15 +330,15 @@ export class AdminController {
@Roles(
Role.PaymentAdmin,
Role.PaymentBaAdmin,
Role.EngagementPaymentApprover,
Role.PaymentApprover,
Role.WiproTaasAdmin,
Role.PaymentEditor,
Role.PaymentViewer,
)
@ApiOperation({
summary: 'List winning audit logs with given winning id',
description:
'Roles: Payment Admin, Payment BA Admin, Engagement Payment Approver, Wipro TaaS Admin, Payment Editor, Payment Viewer',
'Roles: Payment Admin, Payment BA Admin, Payment Approver, Wipro TaaS Admin, Payment Editor, Payment Viewer',
})
@ApiParam({
name: 'winningID',
Expand Down Expand Up @@ -374,15 +374,15 @@ export class AdminController {
@Roles(
Role.PaymentAdmin,
Role.PaymentBaAdmin,
Role.EngagementPaymentApprover,
Role.PaymentApprover,
Role.WiproTaasAdmin,
Role.PaymentEditor,
Role.PaymentViewer,
)
@ApiOperation({
summary: 'Fetch winnings payout audit logs with given winning id.',
description:
'Roles: Payment Admin, Payment BA Admin, Engagement Payment Approver, Wipro TaaS Admin, Payment Editor, Payment Viewer',
'Roles: Payment Admin, Payment BA Admin, Payment Approver, Wipro TaaS Admin, Payment Editor, Payment Viewer',
})
@ApiParam({
name: 'winningID',
Expand Down
245 changes: 241 additions & 4 deletions src/api/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,42 @@ jest.mock('src/shared/topcoder/members.service', () => ({
TopcoderMembersService: class {},
}));

jest.mock('src/shared/topcoder/challenges.service', () => ({
TopcoderChallengesService: class {},
}));

import { AdminService } from './admin.service';
import { PaymentStatus } from 'src/dto/payment.dto';

describe('AdminService', () => {
let service: AdminService;
let prisma: {
$transaction: jest.Mock;
audit: {
create: jest.Mock;
findMany: jest.Mock;
};
payment: {
findMany: jest.Mock;
update: jest.Mock;
};
payment_releases: {
findFirst: jest.Mock;
updateMany: jest.Mock;
};
winnings: {
findFirst: jest.Mock;
};
};
let paymentsService: object;
let baService: object;
let paymentsService: {
reconcileUserPayments: jest.Mock;
};
let baService: {
getBillingAccountById: jest.Mock;
getBillingAccountsForUser: jest.Mock;
lockConsumeAmount: jest.Mock;
syncEngagementConsumeAmounts: jest.Mock;
};
let accessControlService: {
verifyAccess: jest.Mock;
};
Expand All @@ -61,15 +86,43 @@ describe('AdminService', () => {
let tcMembersService: {
getHandlesByUserIds: jest.Mock;
};
let topcoderChallengesService: {
getChallengeById: jest.Mock;
getProjectById: jest.Mock;
};

beforeEach(() => {
prisma = {
$transaction: jest.fn((callback: (tx: unknown) => unknown) =>
Promise.resolve(callback(prisma)),
),
audit: {
create: jest.fn().mockResolvedValue({}),
findMany: jest.fn().mockResolvedValue([]),
},
payment: {
findMany: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue({}),
},
payment_releases: {
findFirst: jest.fn().mockResolvedValue(null),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
winnings: {
findFirst: jest.fn(),
},
};
paymentsService = {};
baService = {};
paymentsService = {
reconcileUserPayments: jest.fn().mockResolvedValue(undefined),
};
baService = {
getBillingAccountById: jest
.fn()
.mockResolvedValue({ id: 80001012, markup: 0.1 }),
getBillingAccountsForUser: jest.fn().mockResolvedValue(['80001012']),
lockConsumeAmount: jest.fn().mockResolvedValue(undefined),
syncEngagementConsumeAmounts: jest.fn().mockResolvedValue(undefined),
};
accessControlService = {
verifyAccess: jest.fn().mockResolvedValue(undefined),
};
Expand All @@ -82,6 +135,10 @@ describe('AdminService', () => {
'654321': 'payment-manager',
}),
};
topcoderChallengesService = {
getChallengeById: jest.fn().mockResolvedValue(undefined),
getProjectById: jest.fn().mockResolvedValue(undefined),
};

service = new AdminService(
prisma as any,
Expand All @@ -90,6 +147,7 @@ describe('AdminService', () => {
accessControlService as any,
topcoderEngagementsService as any,
tcMembersService as any,
topcoderChallengesService as any,
);
});

Expand Down Expand Up @@ -323,4 +381,183 @@ describe('AdminService', () => {
service.getWinningPaymentDetails('missing-winning', '123456', []),
).rejects.toBeInstanceOf(NotFoundException);
});

it('recalculates the challenge billing-account line item when a challenge payment is cancelled', async () => {
prisma.payment.findMany
.mockResolvedValueOnce([
{
billing_account: '80001012',
currency: 'USD',
installment_number: 1,
payment_id: 'payment-1',
payment_status: PaymentStatus.OWED,
release_date: new Date('2026-04-27T00:00:00.000Z'),
version: 1,
winnings: {
category: 'CONTEST_PAYMENT',
external_id: 'challenge-1',
type: 'PAYMENT',
},
},
])
.mockResolvedValueOnce([
{ total_amount: '12.00' },
{ total_amount: '27.50' },
]);
topcoderChallengesService.getChallengeById.mockResolvedValue({
billing: {
billingAccountId: '80001012',
markup: 0.1,
},
id: 'challenge-1',
status: 'COMPLETED',
});

const result = await service.updateWinnings(
{
paymentId: 'payment-1',
paymentStatus: PaymentStatus.CANCELLED,
winningsId: 'winning-1',
} as any,
'admin-1',
['Payment Admin'],
);

expect(result.data).toBe('Successfully updated winnings');
expect(prisma.payment.update).toHaveBeenCalledWith({
where: {
payment_id: 'payment-1',
winnings_id: 'winning-1',
version: 1,
},
data: {
date_paid: undefined,
payment_status: PaymentStatus.CANCELLED,
updated_at: expect.any(Date),
updated_by: 'admin-1',
version: 2,
},
});
expect(prisma.payment.findMany).toHaveBeenNthCalledWith(2, {
select: { total_amount: true },
where: {
billing_account: '80001012',
currency: 'USD',
payment_status: { not: PaymentStatus.CANCELLED },
winnings: {
external_id: 'challenge-1',
type: 'PAYMENT',
},
},
});
expect(baService.lockConsumeAmount).toHaveBeenCalledWith({
billingAccountId: 80001012,
challengeId: 'challenge-1',
markup: 0.1,
status: 'COMPLETED',
totalPrizesInCents: 3950,
});
});

it('releases engagement billing-account rows when an engagement payment is cancelled', async () => {
prisma.payment.findMany
.mockResolvedValueOnce([
{
billing_account: '80001012',
currency: 'USD',
installment_number: 1,
payment_id: 'payment-1',
payment_status: PaymentStatus.OWED,
release_date: new Date('2026-04-28T00:00:00.000Z'),
version: 1,
winnings: {
category: 'ENGAGEMENT_PAYMENT',
external_id: 'assignment-1',
type: 'PAYMENT',
},
},
])
.mockResolvedValueOnce([]);

const result = await service.updateWinnings(
{
paymentId: 'payment-1',
paymentStatus: PaymentStatus.CANCELLED,
winningsId: 'winning-1',
} as any,
'admin-1',
['Payment Admin'],
);

expect(result.data).toBe('Successfully updated winnings');
expect(prisma.payment.findMany).toHaveBeenNthCalledWith(2, {
select: {
challenge_fee: true,
total_amount: true,
},
where: {
billing_account: '80001012',
currency: 'USD',
payment_status: { not: PaymentStatus.CANCELLED },
winnings: {
category: 'ENGAGEMENT_PAYMENT',
external_id: 'assignment-1',
type: 'PAYMENT',
},
},
orderBy: [{ created_at: 'asc' }, { payment_id: 'asc' }],
});
expect(baService.syncEngagementConsumeAmounts).toHaveBeenCalledWith({
amounts: [],
billingAccountId: 80001012,
externalId: 'assignment-1',
});
expect(baService.lockConsumeAmount).not.toHaveBeenCalled();
});

it('returns task details for task payment with projectId and approver', async () => {
prisma.winnings.findFirst.mockResolvedValue({
winning_id: 'winning-task',
category: 'TASK_PAYMENT',
created_by: '654321',
winner_id: '123456',
external_id: 'challenge-uuid-1',
attributes: {},
});
topcoderChallengesService.getChallengeById.mockResolvedValue({
id: 'challenge-uuid-1',
name: 'Build a widget',
projectId: 42,
createdBy: 'challenge-creator',
});
topcoderChallengesService.getProjectById.mockResolvedValue({
id: 42,
name: 'My Project',
});
prisma.audit.findMany.mockResolvedValue([
{
id: 'audit-1',
winnings_id: 'winning-task',
user_id: '654321',
action: 'status updated from ON_HOLD_ADMIN to OWED',
note: null,
created_at: new Date(),
},
]);

const result = await service.getWinningPaymentDetails(
'winning-task',
'123456',
['Payment Admin'],
);

expect(result.data?.taskDetails?.projectId).toBe('42');
expect(result.data?.taskDetails?.projectName).toBe('My Project');
expect(result.data?.taskDetails?.paymentCreatorHandle).toBe(
'challenge-creator',
);
expect(result.data?.taskDetails?.paymentApproverHandle).toBe(
'payment-manager',
);
});
});
Loading
Loading