From 897f962d5c50440ce4e7dc15c68a81122aa89742 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 23 Apr 2026 13:27:18 +0300 Subject: [PATCH 01/19] PM-4826 - make sure all task payments are kept on hold (admin) --- src/api/challenges/challenges.service.spec.ts | 87 +++++++++++++++++++ src/api/challenges/challenges.service.ts | 4 + 2 files changed, 91 insertions(+) diff --git a/src/api/challenges/challenges.service.spec.ts b/src/api/challenges/challenges.service.spec.ts index 8cea8ce..a6035c8 100644 --- a/src/api/challenges/challenges.service.spec.ts +++ b/src/api/challenges/challenges.service.spec.ts @@ -24,6 +24,7 @@ jest.mock('src/shared/global', () => ({ import { ChallengesService } from './challenges.service'; import { PrizeType } from './models'; import { WinningsCategory } from 'src/dto/winning.dto'; +import { PaymentStatus } from 'src/dto/payment.dto'; describe('ChallengesService', () => { it('skips creating payments for fun challenges', async () => { @@ -165,4 +166,90 @@ describe('ChallengesService', () => { }), ]); }); + + it('defaults task challenge winnings to ON_HOLD_ADMIN status', async () => { + const service = new ChallengesService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + + jest + .spyOn(service, 'getChallengeResources') + .mockResolvedValue({ winner: [] } as any); + jest + .spyOn(service, 'generatePlacementWinnersPayments') + .mockReturnValue([ + { + userId: '40158994', + amount: 500, + type: WinningsCategory.TASK_PAYMENT, + currency: PrizeType.USD, + }, + ] as any); + jest + .spyOn(service, 'generateCheckpointWinnersPayments') + .mockReturnValue([]); + jest.spyOn(service, 'generateCopilotPayment').mockReturnValue([]); + jest.spyOn(service, 'generateReviewersPayments').mockResolvedValue([]); + + const winnings = await service.getChallengePayments({ + id: '11111111-1111-1111-1111-111111111111', + name: 'Task Challenge', + type: 'Task', + task: { isTask: true }, + billing: { billingAccountId: '1234', markup: 0.2 }, + } as any); + + expect(winnings).toEqual([ + expect.objectContaining({ + status: PaymentStatus.ON_HOLD_ADMIN, + }), + ]); + }); + + it('does not force ON_HOLD_ADMIN status for non-task challenge winnings', async () => { + const service = new ChallengesService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + + jest + .spyOn(service, 'getChallengeResources') + .mockResolvedValue({ winner: [] } as any); + jest + .spyOn(service, 'generatePlacementWinnersPayments') + .mockReturnValue([ + { + userId: '40158994', + amount: 500, + type: WinningsCategory.CONTEST_PAYMENT, + currency: PrizeType.USD, + }, + ] as any); + jest + .spyOn(service, 'generateCheckpointWinnersPayments') + .mockReturnValue([]); + jest.spyOn(service, 'generateCopilotPayment').mockReturnValue([]); + jest.spyOn(service, 'generateReviewersPayments').mockResolvedValue([]); + + const winnings = await service.getChallengePayments({ + id: '11111111-1111-1111-1111-111111111111', + name: 'Marathon Match', + type: 'Challenge', + task: { isTask: false }, + billing: { billingAccountId: '1234', markup: 0.2 }, + } as any); + + expect(winnings).toEqual([ + expect.not.objectContaining({ + status: PaymentStatus.ON_HOLD_ADMIN, + }), + ]); + }); }); diff --git a/src/api/challenges/challenges.service.ts b/src/api/challenges/challenges.service.ts index ad0c5ef..0434935 100644 --- a/src/api/challenges/challenges.service.ts +++ b/src/api/challenges/challenges.service.ts @@ -27,6 +27,7 @@ import { import { BillingAccountsService } from 'src/shared/topcoder/billing-accounts.service'; import { TopcoderM2MService } from 'src/shared/topcoder/topcoder-m2m.service'; import { ChallengeStatuses } from 'src/dto/challenge.dto'; +import { PaymentStatus } from 'src/dto/payment.dto'; import { WinningsService } from '../winnings/winnings.service'; import { WinningRequestDto, @@ -495,6 +496,9 @@ export class ChallengesService { title: challenge.name, description: payment.description || challenge.name, externalId: challenge.id, + ...(challenge.task?.isTask + ? { status: PaymentStatus.ON_HOLD_ADMIN } + : {}), details: [ { totalAmount: payment.amount, From 06b731791a2627b8ef184b145307487a8b6c31dc Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 24 Apr 2026 08:35:23 +0300 Subject: [PATCH 02/19] PM-4825 - task payment approver --- src/api/admin/admin.controller.ts | 22 ++++---- src/api/repository/winnings.repo.ts | 8 ++- src/core/auth/auth.constants.ts | 2 +- src/dto/winning.dto.ts | 12 ++++- .../access-control/access-control.module.ts | 8 +-- .../access-control.service.spec.ts | 54 +++++++++---------- .../access-control/access-control.service.ts | 2 +- ...ovider.ts => payment-approver.provider.ts} | 24 ++++++--- 8 files changed, 80 insertions(+), 52 deletions(-) rename src/shared/access-control/{engagement-pa.provider.ts => payment-approver.provider.ts} (55%) diff --git a/src/api/admin/admin.controller.ts b/src/api/admin/admin.controller.ts index 5c746b0..de65a50 100644 --- a/src/api/admin/admin.controller.ts +++ b/src/api/admin/admin.controller.ts @@ -56,7 +56,7 @@ export class AdminController { @Roles( Role.PaymentAdmin, Role.PaymentBaAdmin, - Role.EngagementPaymentApprover, + Role.PaymentApprover, Role.WiproTaasAdmin, Role.PaymentEditor, Role.PaymentViewer, @@ -64,7 +64,7 @@ export class AdminController { @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', @@ -102,7 +102,7 @@ export class AdminController { @Roles( Role.PaymentAdmin, Role.PaymentBaAdmin, - Role.EngagementPaymentApprover, + Role.PaymentApprover, Role.WiproTaasAdmin, Role.PaymentEditor, Role.PaymentViewer, @@ -145,7 +145,7 @@ export class AdminController { @Roles( Role.PaymentAdmin, Role.PaymentBaAdmin, - Role.EngagementPaymentApprover, + Role.PaymentApprover, Role.WiproTaasAdmin, Role.PaymentEditor, Role.PaymentViewer, @@ -153,7 +153,7 @@ export class AdminController { @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', @@ -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, @@ -330,7 +330,7 @@ export class AdminController { @Roles( Role.PaymentAdmin, Role.PaymentBaAdmin, - Role.EngagementPaymentApprover, + Role.PaymentApprover, Role.WiproTaasAdmin, Role.PaymentEditor, Role.PaymentViewer, @@ -338,7 +338,7 @@ export class AdminController { @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', @@ -374,7 +374,7 @@ export class AdminController { @Roles( Role.PaymentAdmin, Role.PaymentBaAdmin, - Role.EngagementPaymentApprover, + Role.PaymentApprover, Role.WiproTaasAdmin, Role.PaymentEditor, Role.PaymentViewer, @@ -382,7 +382,7 @@ export class AdminController { @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', diff --git a/src/api/repository/winnings.repo.ts b/src/api/repository/winnings.repo.ts index b9d2479..b6b9d52 100644 --- a/src/api/repository/winnings.repo.ts +++ b/src/api/repository/winnings.repo.ts @@ -94,6 +94,7 @@ export class WinningsRepository { private getWinningsQueryFilters( type?: string, category?: string, + categories?: string[], status?: string, winnerIds?: string[], externalIds?: string[], @@ -120,7 +121,11 @@ export class WinningsRepository { ? { equals: category as winnings_category, } - : undefined, + : categories?.length + ? { + in: categories as winnings_category[], + } + : undefined, type: typeFilter, payment: status ? { @@ -219,6 +224,7 @@ export class WinningsRepository { const queryWhere = this.getWinningsQueryFilters( searchProps.type, searchProps.category, + searchProps.categories, searchProps.status, winnerIds, externalIds, diff --git a/src/core/auth/auth.constants.ts b/src/core/auth/auth.constants.ts index cef4d03..16d3629 100644 --- a/src/core/auth/auth.constants.ts +++ b/src/core/auth/auth.constants.ts @@ -2,7 +2,7 @@ export enum Role { Administrator = 'Administrator', PaymentAdmin = 'Payment Admin', PaymentBaAdmin = 'Payment BA Admin', - EngagementPaymentApprover = 'Engagement Payment Approver', + PaymentApprover = 'Payment Approver', WiproTaasAdmin = 'Wipro TaaS Admin', PaymentEditor = 'Payment Editor', PaymentViewer = 'Payment Viewer', diff --git a/src/dto/winning.dto.ts b/src/dto/winning.dto.ts index 817a4c6..c666929 100644 --- a/src/dto/winning.dto.ts +++ b/src/dto/winning.dto.ts @@ -170,7 +170,17 @@ export class WinningRequestDto extends SortPagination { category?: WinningsCategory; @ApiProperty({ - description: 'The type of winnings', + description: 'Multiple winnings categories to filter by', + enum: WinningsCategory, + isArray: true, + example: [WinningsCategory.ENGAGEMENT_PAYMENT, WinningsCategory.TASK_PAYMENT], + }) + @IsOptional() + @IsArray() + @IsEnum(WinningsCategory, { each: true }) + categories?: WinningsCategory[]; + + @ApiProperty({ enum: WinningsType, example: WinningsType.PAYMENT, }) diff --git a/src/shared/access-control/access-control.module.ts b/src/shared/access-control/access-control.module.ts index 321edf5..a7b919b 100644 --- a/src/shared/access-control/access-control.module.ts +++ b/src/shared/access-control/access-control.module.ts @@ -1,7 +1,7 @@ import { Injectable, Module } from '@nestjs/common'; import { AccessControlService } from 'src/shared/access-control/access-control.service'; import { PaymentBaProvider } from 'src/shared/access-control/payment-ba.provider'; -import { EngagementPaymentApproverProvider } from 'src/shared/access-control/engagement-pa.provider'; +import { PaymentApproverProvider } from 'src/shared/access-control/payment-approver.provider'; import { WiproTaasAdminProvider } from 'src/shared/access-control/wipro-taas-admin.provider'; import { TopcoderModule } from '../topcoder/topcoder.module'; @@ -10,11 +10,11 @@ class AccessControlRegistrar { constructor( accessControlService: AccessControlService, paymentBaProvider: PaymentBaProvider, - engagementPaymentApproverProvider: EngagementPaymentApproverProvider, + paymentApproverProvider: PaymentApproverProvider, wiproTaasAdminProvider: WiproTaasAdminProvider, ) { accessControlService.register(paymentBaProvider); - accessControlService.register(engagementPaymentApproverProvider); + accessControlService.register(paymentApproverProvider); accessControlService.register(wiproTaasAdminProvider); } } @@ -25,7 +25,7 @@ class AccessControlRegistrar { providers: [ AccessControlService, PaymentBaProvider, - EngagementPaymentApproverProvider, + PaymentApproverProvider, WiproTaasAdminProvider, AccessControlRegistrar, ], diff --git a/src/shared/access-control/access-control.service.spec.ts b/src/shared/access-control/access-control.service.spec.ts index 528cd40..85f8a80 100644 --- a/src/shared/access-control/access-control.service.spec.ts +++ b/src/shared/access-control/access-control.service.spec.ts @@ -9,7 +9,7 @@ describe('AccessControlService', () => { service = new AccessControlService(); }); - it('skips engagement approver filters when payment admin role is also present', async () => { + it('skips approver filters when payment admin role is also present', async () => { const paymentAdminApplyFilter = jest .fn() .mockImplementation((_userId, req: Record) => @@ -18,31 +18,31 @@ describe('AccessControlService', () => { admin: true, }), ); - const engagementApproverApplyFilter = jest + const approverApplyFilter = jest .fn() .mockImplementation((_userId, req: Record) => Promise.resolve({ ...req, - category: 'ENGAGEMENT_PAYMENT', + categories: ['ENGAGEMENT_PAYMENT', 'TASK_PAYMENT'], }), ); const paymentAdminProvider: RoleAccessProvider> = { roleName: Role.PaymentAdmin, applyFilter: paymentAdminApplyFilter, }; - const engagementApproverProvider: RoleAccessProvider< + const approverProvider: RoleAccessProvider< Record > = { - roleName: Role.EngagementPaymentApprover, - applyFilter: engagementApproverApplyFilter, + roleName: Role.PaymentApprover, + applyFilter: approverApplyFilter, }; service.register(paymentAdminProvider); - service.register(engagementApproverProvider); + service.register(approverProvider); const result = await service.applyFilters>( '88770025', - [Role.PaymentAdmin, Role.EngagementPaymentApprover], + [Role.PaymentAdmin, Role.PaymentApprover], { type: 'PAYMENT' }, ); @@ -51,38 +51,38 @@ describe('AccessControlService', () => { type: 'PAYMENT', }); expect(paymentAdminApplyFilter).toHaveBeenCalledTimes(1); - expect(engagementApproverApplyFilter).not.toHaveBeenCalled(); + expect(approverApplyFilter).not.toHaveBeenCalled(); }); - it('applies engagement approver filters when payment admin role is absent', async () => { - const engagementApproverApplyFilter = jest + it('applies approver filters when payment admin role is absent', async () => { + const approverApplyFilter = jest .fn() .mockImplementation((_userId, req: Record) => Promise.resolve({ ...req, - category: 'ENGAGEMENT_PAYMENT', + categories: ['ENGAGEMENT_PAYMENT', 'TASK_PAYMENT'], }), ); - const engagementApproverProvider: RoleAccessProvider< + const approverProvider: RoleAccessProvider< Record > = { - roleName: Role.EngagementPaymentApprover, - applyFilter: engagementApproverApplyFilter, + roleName: Role.PaymentApprover, + applyFilter: approverApplyFilter, }; - service.register(engagementApproverProvider); + service.register(approverProvider); const result = await service.applyFilters>( '88770025', - [Role.EngagementPaymentApprover], + [Role.PaymentApprover], { type: 'PAYMENT' }, ); expect(result).toEqual({ - category: 'ENGAGEMENT_PAYMENT', + categories: ['ENGAGEMENT_PAYMENT', 'TASK_PAYMENT'], type: 'PAYMENT', }); - expect(engagementApproverApplyFilter).toHaveBeenCalledTimes(1); + expect(approverApplyFilter).toHaveBeenCalledTimes(1); }); it('applies wipro taas admin filters when payment admin role is absent', async () => { @@ -115,32 +115,32 @@ describe('AccessControlService', () => { expect(wiproTaasAdminApplyFilter).toHaveBeenCalledTimes(1); }); - it('skips engagement approver resource checks when payment admin role is also present', async () => { + it('skips approver resource checks when payment admin role is also present', async () => { const paymentAdminVerifyAccess = jest.fn().mockResolvedValue(undefined); - const engagementApproverVerifyAccess = jest + const approverVerifyAccess = jest .fn() .mockRejectedValue(new Error('should not be called')); const paymentAdminProvider: RoleAccessProvider = { roleName: Role.PaymentAdmin, verifyAccessToResource: paymentAdminVerifyAccess, }; - const engagementApproverProvider: RoleAccessProvider = { - roleName: Role.EngagementPaymentApprover, - verifyAccessToResource: engagementApproverVerifyAccess, + const approverProvider: RoleAccessProvider = { + roleName: Role.PaymentApprover, + verifyAccessToResource: approverVerifyAccess, }; service.register(paymentAdminProvider); - service.register(engagementApproverProvider); + service.register(approverProvider); await expect( service.verifyAccess('winning-id', '88770025', [ Role.PaymentAdmin, - Role.EngagementPaymentApprover, + Role.PaymentApprover, ]), ).resolves.toBeUndefined(); expect(paymentAdminVerifyAccess).toHaveBeenCalledTimes(1); - expect(engagementApproverVerifyAccess).not.toHaveBeenCalled(); + expect(approverVerifyAccess).not.toHaveBeenCalled(); }); it('skips wipro taas admin resource checks when payment admin role is also present', async () => { diff --git a/src/shared/access-control/access-control.service.ts b/src/shared/access-control/access-control.service.ts index cdf1d14..249d515 100644 --- a/src/shared/access-control/access-control.service.ts +++ b/src/shared/access-control/access-control.service.ts @@ -21,7 +21,7 @@ export class AccessControlService { normalizedRoles: Set, ): boolean { const isCategoryScopedApproverRole = - providerRole === Role.EngagementPaymentApprover.trim().toLowerCase() || + providerRole === Role.PaymentApprover.trim().toLowerCase() || providerRole === Role.WiproTaasAdmin.trim().toLowerCase(); return ( diff --git a/src/shared/access-control/engagement-pa.provider.ts b/src/shared/access-control/payment-approver.provider.ts similarity index 55% rename from src/shared/access-control/engagement-pa.provider.ts rename to src/shared/access-control/payment-approver.provider.ts index c4a333e..ae525ea 100644 --- a/src/shared/access-control/engagement-pa.provider.ts +++ b/src/shared/access-control/payment-approver.provider.ts @@ -4,16 +4,28 @@ import { PrismaService } from 'src/shared/global/prisma.service'; import { Role } from 'src/core/auth/auth.constants'; import { winnings_category } from '@prisma/client'; +const allowedCategories: winnings_category[] = [ + winnings_category.ENGAGEMENT_PAYMENT, + winnings_category.TASK_PAYMENT, +]; + @Injectable() -export class EngagementPaymentApproverProvider implements RoleAccessProvider { - roleName = Role.EngagementPaymentApprover; +export class PaymentApproverProvider implements RoleAccessProvider { + roleName = Role.PaymentApprover; constructor(private readonly prisma: PrismaService) {} // disable rule: prefer this format instead of returning resolved promise (required by interface) // eslint-disable-next-line @typescript-eslint/require-await - async applyFilter(userId: string, req: any): Promise { - return { ...req, category: winnings_category.ENGAGEMENT_PAYMENT } as T; + async applyFilter(_userId: string, req: any): Promise { + // If the request already has a specific category, preserve it (access is + // validated per-resource via verifyAccessToResource). Otherwise restrict the + // query to all categories allowed for this role. + if (req.category) { + return req as T; + } + + return { ...req, categories: allowedCategories } as T; } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -26,11 +38,11 @@ export class EngagementPaymentApproverProvider implements RoleAccessProvider { }); const unauthorized = winnings.filter( - (w) => w.category !== winnings_category.ENGAGEMENT_PAYMENT, + (w) => !allowedCategories.includes(w.category), ); if (unauthorized.length > 0) { throw new Error( - `${Role.EngagementPaymentApprover} user is trying to access winning with category='${unauthorized.map((w) => w.category).join(', ')}'`, + `${Role.PaymentApprover} user is trying to access winning with category='${unauthorized.map((w) => w.category).join(', ')}'`, ); } } From c05e1a79eefba1d9adc546af51ca3084660d45dd Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 24 Apr 2026 09:54:24 +0300 Subject: [PATCH 03/19] fix pr feedback --- src/api/repository/winnings.repo.spec.ts | 11 +++ src/api/repository/winnings.repo.ts | 1 + .../payment-approver.provider.spec.ts | 82 +++++++++++++++++++ .../payment-approver.provider.ts | 21 ++--- 4 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 src/shared/access-control/payment-approver.provider.spec.ts diff --git a/src/api/repository/winnings.repo.spec.ts b/src/api/repository/winnings.repo.spec.ts index 128cb0d..badb158 100644 --- a/src/api/repository/winnings.repo.spec.ts +++ b/src/api/repository/winnings.repo.spec.ts @@ -94,4 +94,15 @@ describe('WinningsRepository', () => { expect(createdAtFilter).toBeUndefined(); }); + + it('filters getWinningsByExternalId by external_id instead of winner_id', async () => { + await winningsRepo.getWinningsByExternalId('ext-123'); + + const findManyArgs = findManyMock.mock.calls[0][0]; + + expect(findManyArgs.where.external_id).toEqual({ + in: ['ext-123'], + }); + expect(findManyArgs.where.winner_id).toBeUndefined(); + }); }); diff --git a/src/api/repository/winnings.repo.ts b/src/api/repository/winnings.repo.ts index b6b9d52..fbdaa4b 100644 --- a/src/api/repository/winnings.repo.ts +++ b/src/api/repository/winnings.repo.ts @@ -354,6 +354,7 @@ export class WinningsRepository { undefined, undefined, undefined, + undefined, [externalId], undefined, ); diff --git a/src/shared/access-control/payment-approver.provider.spec.ts b/src/shared/access-control/payment-approver.provider.spec.ts new file mode 100644 index 0000000..c890d32 --- /dev/null +++ b/src/shared/access-control/payment-approver.provider.spec.ts @@ -0,0 +1,82 @@ +jest.mock('src/shared/global/prisma.service', () => ({ + PrismaService: class {}, +})); + +import { winnings_category } from '@prisma/client'; +import { Role } from 'src/core/auth/auth.constants'; +import { PaymentApproverProvider } from './payment-approver.provider'; + +describe('PaymentApproverProvider', () => { + let provider: PaymentApproverProvider; + let prisma: { + winnings: { + findMany: jest.Mock; + }; + }; + + beforeEach(() => { + prisma = { + winnings: { + findMany: jest.fn(), + }, + }; + + provider = new PaymentApproverProvider(prisma as any); + }); + + it('replaces incoming category filters with allowed payment approver categories', async () => { + const result = await provider.applyFilter>( + '123456', + { + categories: [winnings_category.ALGORITHM_CONTEST_PAYMENT], + category: winnings_category.ALGORITHM_CONTEST_PAYMENT, + limit: 10, + type: 'PAYMENT', + }, + ); + + expect(result).toEqual({ + categories: [ + winnings_category.ENGAGEMENT_PAYMENT, + winnings_category.TASK_PAYMENT, + ], + limit: 10, + type: 'PAYMENT', + }); + expect(result).not.toHaveProperty('category'); + }); + + it('allows access when all winnings are approver-allowed categories', async () => { + prisma.winnings.findMany.mockResolvedValue([ + { category: winnings_category.ENGAGEMENT_PAYMENT }, + { category: winnings_category.TASK_PAYMENT }, + ]); + + await expect( + provider.verifyAccessToResource(['winning-1', 'winning-2'], '123456'), + ).resolves.toBeUndefined(); + + expect(prisma.winnings.findMany).toHaveBeenCalledWith({ + where: { winning_id: { in: ['winning-1', 'winning-2'] } }, + select: { category: true }, + }); + }); + + it('rejects access when any winning is outside the allowed categories', async () => { + prisma.winnings.findMany.mockResolvedValue([ + { category: winnings_category.ENGAGEMENT_PAYMENT }, + { category: winnings_category.ALGORITHM_CONTEST_PAYMENT }, + ]); + + await expect( + provider.verifyAccessToResource('winning-1', '123456'), + ).rejects.toThrow( + `${Role.PaymentApprover} user is trying to access winning with category='${winnings_category.ALGORITHM_CONTEST_PAYMENT}'`, + ); + + expect(prisma.winnings.findMany).toHaveBeenCalledWith({ + where: { winning_id: { in: ['winning-1'] } }, + select: { category: true }, + }); + }); +}); diff --git a/src/shared/access-control/payment-approver.provider.ts b/src/shared/access-control/payment-approver.provider.ts index ae525ea..943a3ed 100644 --- a/src/shared/access-control/payment-approver.provider.ts +++ b/src/shared/access-control/payment-approver.provider.ts @@ -18,14 +18,13 @@ export class PaymentApproverProvider implements RoleAccessProvider { // disable rule: prefer this format instead of returning resolved promise (required by interface) // eslint-disable-next-line @typescript-eslint/require-await async applyFilter(_userId: string, req: any): Promise { - // If the request already has a specific category, preserve it (access is - // validated per-resource via verifyAccessToResource). Otherwise restrict the - // query to all categories allowed for this role. - if (req.category) { - return req as T; - } + const { category: _ignoredCategory, categories: _ignoredCategories, ...rest } = + req ?? {}; - return { ...req, categories: allowedCategories } as T; + return { + ...rest, + categories: allowedCategories, + } as T; } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -37,9 +36,11 @@ export class PaymentApproverProvider implements RoleAccessProvider { select: { category: true }, }); - const unauthorized = winnings.filter( - (w) => !allowedCategories.includes(w.category), - ); + const unauthorized = winnings.filter((w) => { + const category = w.category; + + return category === null || !allowedCategories.includes(category); + }); if (unauthorized.length > 0) { throw new Error( `${Role.PaymentApprover} user is trying to access winning with category='${unauthorized.map((w) => w.category).join(', ')}'`, From 20c901a0ee274d4b5f466cf3b80ccbd186f35460 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 24 Apr 2026 15:00:23 +0300 Subject: [PATCH 04/19] PM-4825 - show task payment details --- src/api/admin/admin.service.spec.ts | 57 ++++++++++++++++++++ src/api/admin/admin.service.ts | 64 +++++++++++++++++++++++ src/api/admin/dto/payment-details.dto.ts | 26 +++++++++ src/shared/topcoder/challenges.service.ts | 50 +++++++++++++++++- 4 files changed, 196 insertions(+), 1 deletion(-) diff --git a/src/api/admin/admin.service.spec.ts b/src/api/admin/admin.service.spec.ts index d74b63f..256d236 100644 --- a/src/api/admin/admin.service.spec.ts +++ b/src/api/admin/admin.service.spec.ts @@ -45,6 +45,9 @@ import { AdminService } from './admin.service'; describe('AdminService', () => { let service: AdminService; let prisma: { + audit: { + findMany: jest.Mock; + }; winnings: { findFirst: jest.Mock; }; @@ -61,9 +64,16 @@ describe('AdminService', () => { let tcMembersService: { getHandlesByUserIds: jest.Mock; }; + let topcoderChallengesService: { + getChallengeById: jest.Mock; + getProjectById: jest.Mock; + }; beforeEach(() => { prisma = { + audit: { + findMany: jest.fn().mockResolvedValue([]), + }, winnings: { findFirst: jest.fn(), }, @@ -82,6 +92,10 @@ describe('AdminService', () => { '654321': 'payment-manager', }), }; + topcoderChallengesService = { + getChallengeById: jest.fn().mockResolvedValue(undefined), + getProjectById: jest.fn().mockResolvedValue(undefined), + }; service = new AdminService( prisma as any, @@ -90,6 +104,7 @@ describe('AdminService', () => { accessControlService as any, topcoderEngagementsService as any, tcMembersService as any, + topcoderChallengesService as any, ); }); @@ -323,4 +338,46 @@ describe('AdminService', () => { service.getWinningPaymentDetails('missing-winning', '123456', []), ).rejects.toBeInstanceOf(NotFoundException); }); + + 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, + }); + 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?.paymentApproverHandle).toBe( + 'payment-manager', + ); + }); }); diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 46ed714..e069c7e 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -23,6 +23,7 @@ import { TopcoderEngagementsService, } from 'src/shared/topcoder/engagements.service'; import { TopcoderMembersService } from 'src/shared/topcoder/members.service'; +import { TopcoderChallengesService } from 'src/shared/topcoder/challenges.service'; import { WinningPaymentDetailsDto } from './dto/payment-details.dto'; /** @@ -43,6 +44,7 @@ export class AdminService { private readonly accessControlService: AccessControlService, private readonly topcoderEngagementsService: TopcoderEngagementsService, private readonly tcMembersService: TopcoderMembersService, + private readonly topcoderChallengesService: TopcoderChallengesService, ) {} async verifyUserAccessToWinning( @@ -712,6 +714,58 @@ export class AdminService { return result; } + private async buildTaskDetails( + winningsId: string, + externalId: string | undefined, + ): Promise { + const taskDetails: WinningPaymentDetailsDto['taskDetails'] = {}; + + if (externalId) { + try { + const challenge = + await this.topcoderChallengesService.getChallengeById(externalId); + if (challenge?.projectId) { + taskDetails.projectId = String(challenge.projectId); + try { + const project = await this.topcoderChallengesService.getProjectById( + challenge.projectId, + ); + if (project?.name) { + taskDetails.projectName = project.name; + } + } catch { + // project name is optional — ignore failures + } + } + } catch { + // projectId is optional — ignore failures + } + } + + try { + const audits = await this.prisma.audit.findMany({ + where: { winnings_id: winningsId }, + orderBy: { created_at: 'asc' }, + take: 200, + }); + + const approverAudit = audits.find( + (a) => + typeof a.action === 'string' && + a.action.indexOf('ON_HOLD_ADMIN') < a.action.indexOf('OWED'), + ); + if (approverAudit?.user_id) { + const userId = String(approverAudit.user_id); + const handle = await this.getPaymentCreatorHandle(userId); + taskDetails.paymentApproverHandle = handle ?? undefined; + } + } catch { + // approver is optional — ignore failures + } + + return taskDetails; + } + async getWinningPaymentDetails( winningsId: string, userId: string, @@ -747,6 +801,16 @@ export class AdminService { const assignmentLookupId = assignmentId ?? externalId; const isEngagementPayment = winning.category === 'ENGAGEMENT_PAYMENT'; + const isTaskPayment = winning.category === 'TASK_PAYMENT'; + + if (isTaskPayment) { + result.data.taskDetails = await this.buildTaskDetails( + winningsId, + externalId, + ); + return result; + } + if (!isEngagementPayment) { return result; } diff --git a/src/api/admin/dto/payment-details.dto.ts b/src/api/admin/dto/payment-details.dto.ts index 55f66ac..bfb30fa 100644 --- a/src/api/admin/dto/payment-details.dto.ts +++ b/src/api/admin/dto/payment-details.dto.ts @@ -76,6 +76,26 @@ export class PaymentWorkLogDto { remarks?: string; } +export class PaymentTaskDetailsDto { + @ApiPropertyOptional({ + description: 'The Connect project ID associated with the task challenge', + example: '12345', + }) + projectId?: string; + + @ApiPropertyOptional({ + description: 'The name of the project associated with the task challenge', + example: 'Platform Modernization', + }) + projectName?: string; + + @ApiPropertyOptional({ + description: 'The Topcoder handle of the user who approved this payment', + example: 'approver_handle', + }) + paymentApproverHandle?: string; +} + export class WinningPaymentDetailsDto { @ApiPropertyOptional({ description: @@ -96,4 +116,10 @@ export class WinningPaymentDetailsDto { type: PaymentWorkLogDto, }) workLog?: PaymentWorkLogDto; + + @ApiPropertyOptional({ + description: 'Task-specific details when the winning is a task payment', + type: PaymentTaskDetailsDto, + }) + taskDetails?: PaymentTaskDetailsDto; } diff --git a/src/shared/topcoder/challenges.service.ts b/src/shared/topcoder/challenges.service.ts index 063a2de..c3d1d9d 100644 --- a/src/shared/topcoder/challenges.service.ts +++ b/src/shared/topcoder/challenges.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { TopcoderM2MService } from './topcoder-m2m.service'; +import { ENV_CONFIG } from 'src/config'; import { Logger } from 'src/shared/global'; +import { TopcoderM2MService } from './topcoder-m2m.service'; + +const { TOPCODER_API_V6_BASE_URL: TC_API_V6_BASE } = ENV_CONFIG; + export interface WithdrawUpdateData { userId: number; status: string; @@ -14,9 +18,53 @@ export interface AdminPaymentUpdateData { amount: number; releaseDate: string; } + +export interface TopcoderChallengeInfo { + id: string; + name: string; + projectId: number; +} + +export interface TopcoderProjectInfo { + id: number; + name: string; +} + @Injectable() export class TopcoderChallengesService { private readonly logger = new Logger(TopcoderChallengesService.name); constructor(private readonly m2MService: TopcoderM2MService) {} + + async getChallengeById( + challengeId: string, + ): Promise { + try { + return await this.m2MService.m2mFetch( + `${TC_API_V6_BASE}/challenges/${challengeId}`, + ); + } catch (error) { + this.logger.warn( + `Failed to fetch challenge ${challengeId}`, + error instanceof Error ? error.message : error, + ); + return undefined; + } + } + + async getProjectById( + projectId: number, + ): Promise { + try { + return await this.m2MService.m2mFetch( + `${TC_API_V6_BASE}/projects/${projectId}`, + ); + } catch (error) { + this.logger.warn( + `Failed to fetch project ${projectId}`, + error instanceof Error ? error.message : error, + ); + return undefined; + } + } } From 38c07c846482ac5b8c9ec53759e82681d9c4fdca Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 24 Apr 2026 15:22:34 +0300 Subject: [PATCH 05/19] fix check for approver audit --- src/api/admin/admin.service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index e069c7e..0827c81 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -752,6 +752,8 @@ export class AdminService { const approverAudit = audits.find( (a) => typeof a.action === 'string' && + a.action.includes('ON_HOLD_ADMIN') && + a.action.includes('OWED') && a.action.indexOf('ON_HOLD_ADMIN') < a.action.indexOf('OWED'), ); if (approverAudit?.user_id) { From da1783d4f7db06b599445d0e8a0ecda74d7cae4d Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 24 Apr 2026 15:32:31 +0300 Subject: [PATCH 06/19] fix test --- src/api/admin/admin.service.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/api/admin/admin.service.spec.ts b/src/api/admin/admin.service.spec.ts index 256d236..fa17271 100644 --- a/src/api/admin/admin.service.spec.ts +++ b/src/api/admin/admin.service.spec.ts @@ -40,6 +40,10 @@ jest.mock('src/shared/topcoder/members.service', () => ({ TopcoderMembersService: class {}, })); +jest.mock('src/shared/topcoder/challenges.service', () => ({ + TopcoderChallengesService: class {}, +})); + import { AdminService } from './admin.service'; describe('AdminService', () => { From cdd4aa5d261ada6e3c6d8aff73748485caed8e0b Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 24 Apr 2026 15:41:38 +0300 Subject: [PATCH 07/19] PM-4830 - fix order --- src/api/admin/admin.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 0827c81..3938901 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -745,10 +745,9 @@ export class AdminService { try { const audits = await this.prisma.audit.findMany({ where: { winnings_id: winningsId }, - orderBy: { created_at: 'asc' }, + orderBy: { created_at: 'desc' }, take: 200, }); - const approverAudit = audits.find( (a) => typeof a.action === 'string' && From 954de5a68babadd758ee910e8b5bf10c5569f5d3 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 27 Apr 2026 09:31:29 +1000 Subject: [PATCH 08/19] Make sure consume value for engagement payments includes the challenge fee --- src/api/challenges/challenges.service.spec.ts | 4 +++- src/api/winnings/winnings.service.spec.ts | 10 ++++++++++ src/api/winnings/winnings.service.ts | 11 ++++++----- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/api/challenges/challenges.service.spec.ts b/src/api/challenges/challenges.service.spec.ts index 8cea8ce..538e544 100644 --- a/src/api/challenges/challenges.service.spec.ts +++ b/src/api/challenges/challenges.service.spec.ts @@ -139,7 +139,9 @@ describe('ChallengesService', () => { id: '11111111-1111-1111-1111-111111111111', name: 'Topgear Review Challenge', metadata: [{ name: 'payment_type', value: 'topgear' }], - prizeSets: [{ type: 'PLACEMENT', prizes: [{ type: PrizeType.USD, value: 500 }] }], + prizeSets: [ + { type: 'PLACEMENT', prizes: [{ type: PrizeType.USD, value: 500 }] }, + ], reviewers: [ { isMemberReview: true, diff --git a/src/api/winnings/winnings.service.spec.ts b/src/api/winnings/winnings.service.spec.ts index 96cfbfe..50f5437 100644 --- a/src/api/winnings/winnings.service.spec.ts +++ b/src/api/winnings/winnings.service.spec.ts @@ -226,6 +226,16 @@ describe('WinningsService', () => { expect(Number(persistedPayment.challenge_markup)).toBe(0.24); expect(Number(persistedPayment.challenge_fee)).toBe(24); + expect(billingAccountsService.consumeAmounts).toHaveBeenCalledWith({ + consumes: [ + { + amount: 124, + billingAccountId: 123456, + externalId: 'assignment-1', + externalType: 'ENGAGEMENT', + }, + ], + }); }); it('rejects engagement payment details that do not match the assignment billing account', async () => { diff --git a/src/api/winnings/winnings.service.ts b/src/api/winnings/winnings.service.ts index 9524cd3..bb44062 100644 --- a/src/api/winnings/winnings.service.ts +++ b/src/api/winnings/winnings.service.ts @@ -398,7 +398,8 @@ export class WinningsService { * Computes an engagement consume amount with decimal-safe arithmetic. * * @param totalAmount payment detail total amount. - * @param markup billing-account markup. + * @param challengeMarkup rounded billing-account markup persisted on the + * payment row. * @param detailIndex zero-based detail index for error reporting. * @returns Ledger-scale consume amount including markup. * @throws BadRequestException when the detail amount is invalid. @@ -407,7 +408,7 @@ export class WinningsService { */ private calculateEngagementConsumeAmount( totalAmount: number, - markup: number, + challengeMarkup: number, detailIndex: number, ): number { if (!Number.isFinite(totalAmount) || totalAmount < 0) { @@ -416,14 +417,14 @@ export class WinningsService { ); } - if (!Number.isFinite(markup)) { + if (!Number.isFinite(challengeMarkup)) { throw new InternalServerErrorException( 'Engagement billing account has invalid markup', ); } const totalAmountDecimal = new Prisma.Decimal(totalAmount); - const markupDecimal = new Prisma.Decimal(markup); + const markupDecimal = new Prisma.Decimal(challengeMarkup); const consumeAmount = totalAmountDecimal.plus( totalAmountDecimal.mul(markupDecimal), ); @@ -539,7 +540,7 @@ export class WinningsService { consumePlan.push({ amount: this.calculateEngagementConsumeAmount( Number(detail.totalAmount), - billingAccount.markup, + challengeMarkup, detailIndex, ), billingAccountId: trustedBillingAccountId, From 67bdb630e974e9246f58e9d011f32d629773f517 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 27 Apr 2026 12:23:49 +0300 Subject: [PATCH 09/19] PM-4830 - put on hold payments for submitters & reviewers only --- src/api/challenges/challenges.service.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/api/challenges/challenges.service.ts b/src/api/challenges/challenges.service.ts index 0434935..8846963 100644 --- a/src/api/challenges/challenges.service.ts +++ b/src/api/challenges/challenges.service.ts @@ -44,6 +44,7 @@ interface PaymentPayload { type: WinningsCategory; currency: PrizeType; description?: string; + status?: PaymentStatus; } const placeToOrdinal = (place: number) => { @@ -213,6 +214,9 @@ export class ChallengesService { userId: winner.userId.toString(), type: winType, currency, + ...(challenge.task?.isTask + ? { status: PaymentStatus.ON_HOLD_ADMIN } + : {}), description: challenge.type === 'Task' ? challenge.name @@ -422,6 +426,9 @@ export class ChallengesService { ), type: winType, currency: placementPrizes?.[0]?.type ?? PrizeType.USD, + ...(challenge.task?.isTask + ? { status: PaymentStatus.ON_HOLD_ADMIN } + : {}), description: `${challenge.name} - ${phaseReviews[0].phaseName}`, }; }, @@ -496,9 +503,7 @@ export class ChallengesService { title: challenge.name, description: payment.description || challenge.name, externalId: challenge.id, - ...(challenge.task?.isTask - ? { status: PaymentStatus.ON_HOLD_ADMIN } - : {}), + ...(payment.status ? { status: payment.status } : {}), details: [ { totalAmount: payment.amount, From 4d819cd246d1c250f991b4e2e3cceed9f0f7a69f Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 27 Apr 2026 12:36:57 +0300 Subject: [PATCH 10/19] Make sure filters are applied for restricted categories --- .../payment-approver.provider.spec.ts | 67 +++++++++++++++++++ .../payment-approver.provider.ts | 21 +++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/shared/access-control/payment-approver.provider.spec.ts b/src/shared/access-control/payment-approver.provider.spec.ts index c890d32..223f5f7 100644 --- a/src/shared/access-control/payment-approver.provider.spec.ts +++ b/src/shared/access-control/payment-approver.provider.spec.ts @@ -46,6 +46,73 @@ describe('PaymentApproverProvider', () => { expect(result).not.toHaveProperty('category'); }); + it('filters to a single allowed category when client requests only task payments', async () => { + const result = await provider.applyFilter>( + '123456', + { + category: winnings_category.TASK_PAYMENT, + limit: 10, + }, + ); + + expect(result).toEqual({ + categories: [winnings_category.TASK_PAYMENT], + limit: 10, + }); + }); + + it('filters to a single allowed category when client requests only engagement payments', async () => { + const result = await provider.applyFilter>( + '123456', + { + category: winnings_category.ENGAGEMENT_PAYMENT, + limit: 10, + }, + ); + + expect(result).toEqual({ + categories: [winnings_category.ENGAGEMENT_PAYMENT], + limit: 10, + }); + }); + + it('intersects client categories array with allowed categories', async () => { + const result = await provider.applyFilter>( + '123456', + { + categories: [ + winnings_category.TASK_PAYMENT, + winnings_category.ALGORITHM_CONTEST_PAYMENT, + winnings_category.ENGAGEMENT_PAYMENT, + ], + limit: 10, + }, + ); + + expect(result).toEqual({ + categories: [ + winnings_category.TASK_PAYMENT, + winnings_category.ENGAGEMENT_PAYMENT, + ], + limit: 10, + }); + }); + + it('defaults to all allowed categories when no filter is supplied', async () => { + const result = await provider.applyFilter>( + '123456', + { limit: 10 }, + ); + + expect(result).toEqual({ + categories: [ + winnings_category.ENGAGEMENT_PAYMENT, + winnings_category.TASK_PAYMENT, + ], + limit: 10, + }); + }); + it('allows access when all winnings are approver-allowed categories', async () => { prisma.winnings.findMany.mockResolvedValue([ { category: winnings_category.ENGAGEMENT_PAYMENT }, diff --git a/src/shared/access-control/payment-approver.provider.ts b/src/shared/access-control/payment-approver.provider.ts index 943a3ed..24f15ca 100644 --- a/src/shared/access-control/payment-approver.provider.ts +++ b/src/shared/access-control/payment-approver.provider.ts @@ -18,12 +18,27 @@ export class PaymentApproverProvider implements RoleAccessProvider { // disable rule: prefer this format instead of returning resolved promise (required by interface) // eslint-disable-next-line @typescript-eslint/require-await async applyFilter(_userId: string, req: any): Promise { - const { category: _ignoredCategory, categories: _ignoredCategories, ...rest } = - req ?? {}; + const { category, categories, ...rest } = req ?? {}; + + // If client supplied a filter, intersect with allowed categories for security. + // If the intersection is empty (all disallowed), fallback to all allowed. + // Otherwise default to all allowed categories. + let requestedCategories: winnings_category[] | undefined; + + if (categories && Array.isArray(categories)) { + const filtered = categories.filter((cat) => + allowedCategories.includes(cat), + ); + requestedCategories = filtered.length > 0 ? filtered : undefined; + } else if (category) { + requestedCategories = allowedCategories.includes(category) + ? [category] + : undefined; + } return { ...rest, - categories: allowedCategories, + categories: requestedCategories ?? allowedCategories, } as T; } From 29c994ff62b0b7f2ee9039c9eb0dc06ca6d41e9d Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 27 Apr 2026 12:51:32 +0300 Subject: [PATCH 11/19] Fix status for on hold admin --- src/api/challenges/challenges.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/challenges/challenges.service.ts b/src/api/challenges/challenges.service.ts index 8846963..fdcfaa7 100644 --- a/src/api/challenges/challenges.service.ts +++ b/src/api/challenges/challenges.service.ts @@ -214,7 +214,7 @@ export class ChallengesService { userId: winner.userId.toString(), type: winType, currency, - ...(challenge.task?.isTask + ...(challenge.task?.isTask && currency === PrizeType.USD ? { status: PaymentStatus.ON_HOLD_ADMIN } : {}), description: @@ -426,7 +426,7 @@ export class ChallengesService { ), type: winType, currency: placementPrizes?.[0]?.type ?? PrizeType.USD, - ...(challenge.task?.isTask + ...(challenge.task?.isTask && currency === PrizeType.USD ? { status: PaymentStatus.ON_HOLD_ADMIN } : {}), description: `${challenge.name} - ${phaseReviews[0].phaseName}`, From 02021dcb5f35c8ae21b2a0cc4ba07988eca76754 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 27 Apr 2026 22:07:53 +0300 Subject: [PATCH 12/19] PM-4830 - task creator details --- src/api/admin/admin.service.spec.ts | 4 ++++ src/api/admin/admin.service.ts | 5 +++++ src/api/admin/dto/payment-details.dto.ts | 6 ++++++ src/shared/topcoder/challenges.service.ts | 1 + 4 files changed, 16 insertions(+) diff --git a/src/api/admin/admin.service.spec.ts b/src/api/admin/admin.service.spec.ts index fa17271..6e670b1 100644 --- a/src/api/admin/admin.service.spec.ts +++ b/src/api/admin/admin.service.spec.ts @@ -356,6 +356,7 @@ describe('AdminService', () => { id: 'challenge-uuid-1', name: 'Build a widget', projectId: 42, + createdBy: 'challenge-creator', }); topcoderChallengesService.getProjectById.mockResolvedValue({ id: 42, @@ -380,6 +381,9 @@ describe('AdminService', () => { 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', ); diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 3938901..21cf892 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -724,6 +724,11 @@ export class AdminService { try { const challenge = await this.topcoderChallengesService.getChallengeById(externalId); + if (challenge?.createdBy) { + taskDetails.paymentCreatorHandle = await this.getPaymentCreatorHandle( + challenge.createdBy + ); + } if (challenge?.projectId) { taskDetails.projectId = String(challenge.projectId); try { diff --git a/src/api/admin/dto/payment-details.dto.ts b/src/api/admin/dto/payment-details.dto.ts index bfb30fa..95ebfdd 100644 --- a/src/api/admin/dto/payment-details.dto.ts +++ b/src/api/admin/dto/payment-details.dto.ts @@ -94,6 +94,12 @@ export class PaymentTaskDetailsDto { example: 'approver_handle', }) paymentApproverHandle?: string; + + @ApiPropertyOptional({ + description: 'The Topcoder handle of the challenge creator', + example: 'task_creator_handle', + }) + paymentCreatorHandle?: string; } export class WinningPaymentDetailsDto { diff --git a/src/shared/topcoder/challenges.service.ts b/src/shared/topcoder/challenges.service.ts index c3d1d9d..4e65884 100644 --- a/src/shared/topcoder/challenges.service.ts +++ b/src/shared/topcoder/challenges.service.ts @@ -23,6 +23,7 @@ export interface TopcoderChallengeInfo { id: string; name: string; projectId: number; + createdBy?: string; } export interface TopcoderProjectInfo { From 6f3fdcf1512b3f2d385e3869cacb592a2fab426e Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 28 Apr 2026 10:09:45 +1000 Subject: [PATCH 13/19] PM-4951: Lock draft challenge payment budget What was broken Payments created for challenges that were still in Draft did not create a locked billing-account budget row, so the Billing Account Details modal did not include those draft challenge payments as Locked. Root cause (if identifiable) The finance billing-account helper only treated Active and Approved challenges as lockable, and manual challenge payment creation did not synchronize the aggregate challenge payment total into the billing-account ledger. What was changed Draft challenges now use the locked budget path. Manual challenge payment creation resolves the challenge, aggregates persisted USD payments for that challenge and billing account, and writes the aggregate amount to the billing-account locked or consumed row based on the challenge status. Generated challenge payment batches mark their per-winning requests so the existing aggregate sync remains the single writer. Any added/updated tests Added coverage for Draft challenge budget locking and manual challenge payment budget sync. Updated challenge payment generation tests to assert the internal budget-sync skip marker. --- src/api/admin/admin.service.ts | 2 +- src/api/challenges/challenges.service.spec.ts | 40 +-- src/api/challenges/challenges.service.ts | 72 +++-- src/api/winnings/winnings.service.spec.ts | 112 ++++++- src/api/winnings/winnings.service.ts | 273 +++++++++++++++++- src/dto/winning.dto.ts | 5 +- .../access-control.service.spec.ts | 8 +- .../topcoder/billing-accounts.service.spec.ts | 52 ++++ .../topcoder/billing-accounts.service.ts | 24 +- src/shared/topcoder/challenges.service.ts | 8 +- 10 files changed, 528 insertions(+), 68 deletions(-) create mode 100644 src/shared/topcoder/billing-accounts.service.spec.ts diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 21cf892..8f54306 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -726,7 +726,7 @@ export class AdminService { await this.topcoderChallengesService.getChallengeById(externalId); if (challenge?.createdBy) { taskDetails.paymentCreatorHandle = await this.getPaymentCreatorHandle( - challenge.createdBy + challenge.createdBy, ); } if (challenge?.projectId) { diff --git a/src/api/challenges/challenges.service.spec.ts b/src/api/challenges/challenges.service.spec.ts index f97d508..0318e91 100644 --- a/src/api/challenges/challenges.service.spec.ts +++ b/src/api/challenges/challenges.service.spec.ts @@ -25,6 +25,7 @@ import { ChallengesService } from './challenges.service'; import { PrizeType } from './models'; import { WinningsCategory } from 'src/dto/winning.dto'; import { PaymentStatus } from 'src/dto/payment.dto'; +import { CHALLENGE_BUDGET_SYNC_SKIP_ATTRIBUTE } from '../winnings/winnings.service'; describe('ChallengesService', () => { it('skips creating payments for fun challenges', async () => { @@ -181,16 +182,14 @@ describe('ChallengesService', () => { jest .spyOn(service, 'getChallengeResources') .mockResolvedValue({ winner: [] } as any); - jest - .spyOn(service, 'generatePlacementWinnersPayments') - .mockReturnValue([ - { - userId: '40158994', - amount: 500, - type: WinningsCategory.TASK_PAYMENT, - currency: PrizeType.USD, - }, - ] as any); + jest.spyOn(service, 'generatePlacementWinnersPayments').mockReturnValue([ + { + userId: '40158994', + amount: 500, + type: WinningsCategory.TASK_PAYMENT, + currency: PrizeType.USD, + }, + ] as any); jest .spyOn(service, 'generateCheckpointWinnersPayments') .mockReturnValue([]); @@ -210,6 +209,9 @@ describe('ChallengesService', () => { status: PaymentStatus.ON_HOLD_ADMIN, }), ]); + expect(winnings[0].attributes).toMatchObject({ + [CHALLENGE_BUDGET_SYNC_SKIP_ATTRIBUTE]: true, + }); }); it('does not force ON_HOLD_ADMIN status for non-task challenge winnings', async () => { @@ -224,16 +226,14 @@ describe('ChallengesService', () => { jest .spyOn(service, 'getChallengeResources') .mockResolvedValue({ winner: [] } as any); - jest - .spyOn(service, 'generatePlacementWinnersPayments') - .mockReturnValue([ - { - userId: '40158994', - amount: 500, - type: WinningsCategory.CONTEST_PAYMENT, - currency: PrizeType.USD, - }, - ] as any); + jest.spyOn(service, 'generatePlacementWinnersPayments').mockReturnValue([ + { + userId: '40158994', + amount: 500, + type: WinningsCategory.CONTEST_PAYMENT, + currency: PrizeType.USD, + }, + ] as any); jest .spyOn(service, 'generateCheckpointWinnersPayments') .mockReturnValue([]); diff --git a/src/api/challenges/challenges.service.ts b/src/api/challenges/challenges.service.ts index fdcfaa7..b884034 100644 --- a/src/api/challenges/challenges.service.ts +++ b/src/api/challenges/challenges.service.ts @@ -28,7 +28,10 @@ import { BillingAccountsService } from 'src/shared/topcoder/billing-accounts.ser import { TopcoderM2MService } from 'src/shared/topcoder/topcoder-m2m.service'; import { ChallengeStatuses } from 'src/dto/challenge.dto'; import { PaymentStatus } from 'src/dto/payment.dto'; -import { WinningsService } from '../winnings/winnings.service'; +import { + CHALLENGE_BUDGET_SYNC_SKIP_ATTRIBUTE, + WinningsService, +} from '../winnings/winnings.service'; import { WinningRequestDto, WinningsCategory, @@ -492,36 +495,45 @@ export class ChallengesService { 0, ); - return payments.map((payment) => ({ - winnerId: payment.userId.toString(), - type: - payment.currency === PrizeType.USD - ? WinningsType.PAYMENT - : WinningsType.POINTS, - origin: 'Topcoder', - category: payment.type, - title: challenge.name, - description: payment.description || challenge.name, - externalId: challenge.id, - ...(payment.status ? { status: payment.status } : {}), - details: [ - { - totalAmount: payment.amount, - grossAmount: payment.amount, - installmentNumber: 1, - currency: payment.currency || PrizeType.USD, - billingAccount: `${challenge.billing.billingAccountId}`, - challengeFee: totalUsdAmount * challenge.billing.markup, + return payments.map((payment) => { + const paymentStatus = + payment.status ?? + (challenge.task?.isTask && payment.currency === PrizeType.USD + ? PaymentStatus.ON_HOLD_ADMIN + : undefined); + + return { + winnerId: payment.userId.toString(), + type: + payment.currency === PrizeType.USD + ? WinningsType.PAYMENT + : WinningsType.POINTS, + origin: 'Topcoder', + category: payment.type, + title: challenge.name, + description: payment.description || challenge.name, + externalId: challenge.id, + ...(paymentStatus ? { status: paymentStatus } : {}), + details: [ + { + totalAmount: payment.amount, + grossAmount: payment.amount, + installmentNumber: 1, + currency: payment.currency || PrizeType.USD, + billingAccount: `${challenge.billing.billingAccountId}`, + challengeFee: totalUsdAmount * challenge.billing.markup, + }, + ], + attributes: { + billingAccountId: challenge.billing.billingAccountId, + [CHALLENGE_BUDGET_SYNC_SKIP_ATTRIBUTE]: true, + payroll: includes( + TGBillingAccounts, + parseInt(challenge.billing.billingAccountId), + ), }, - ], - attributes: { - billingAccountId: challenge.billing.billingAccountId, - payroll: includes( - TGBillingAccounts, - parseInt(challenge.billing.billingAccountId), - ), - }, - })); + }; + }); } private async createPayments(challenge: Challenge, userId: string) { diff --git a/src/api/winnings/winnings.service.spec.ts b/src/api/winnings/winnings.service.spec.ts index 50f5437..32d161e 100644 --- a/src/api/winnings/winnings.service.spec.ts +++ b/src/api/winnings/winnings.service.spec.ts @@ -24,11 +24,17 @@ import { BadRequestException, InternalServerErrorException, } from '@nestjs/common'; -import { WinningsService } from './winnings.service'; +import { + CHALLENGE_BUDGET_SYNC_SKIP_ATTRIBUTE, + WinningsService, +} from './winnings.service'; import { PrizeType } from '../challenges/models'; import { WinningsCategory, WinningsType } from 'src/dto/winning.dto'; interface TestTransactionClient { + payment: { + findMany: jest.Mock; + }; winnings: { create: jest.Mock; }; @@ -47,6 +53,10 @@ describe('WinningsService', () => { let billingAccountsService: { consumeAmounts: jest.Mock; getBillingAccountById: jest.Mock; + lockConsumeAmount: jest.Mock; + }; + let topcoderChallengesService: { + getChallengeById: jest.Mock; }; let topcoderEngagementsService: { getAssignmentContextById: jest.Mock; @@ -54,6 +64,9 @@ describe('WinningsService', () => { beforeEach(() => { tx = { + payment: { + findMany: jest.fn().mockResolvedValue([]), + }, winnings: { create: jest.fn().mockResolvedValue({ winning_id: 'winning-1' }), }, @@ -70,6 +83,10 @@ describe('WinningsService', () => { getBillingAccountById: jest .fn() .mockResolvedValue({ id: 123456, markup: 0.2 }), + lockConsumeAmount: jest.fn().mockResolvedValue(undefined), + }; + topcoderChallengesService = { + getChallengeById: jest.fn().mockResolvedValue(undefined), }; topcoderEngagementsService = { getAssignmentContextById: jest.fn().mockResolvedValue({ @@ -88,6 +105,7 @@ describe('WinningsService', () => { completedIdentityVerification: jest.fn().mockResolvedValue(true), } as any, {} as any, + topcoderChallengesService as any, topcoderEngagementsService as any, billingAccountsService as any, ); @@ -421,4 +439,96 @@ describe('WinningsService', () => { expect(Number(persistedPayment.challenge_markup)).toBe(0.2); expect(Number(persistedPayment.challenge_fee)).toBe(20); }); + + it('locks the aggregate billing-account amount for draft challenge payments', async () => { + topcoderChallengesService.getChallengeById.mockResolvedValue({ + billing: { + markup: 0.2, + }, + id: 'challenge-id', + status: 'DRAFT', + }); + tx.payment.findMany.mockResolvedValue([ + { total_amount: '100.00' }, + { total_amount: '50.25' }, + ]); + + await service.createWinningWithPayments( + { + winnerId: 'user-1', + type: WinningsType.PAYMENT, + origin: 'Topcoder', + category: WinningsCategory.CONTEST_PAYMENT, + title: 'Draft challenge payment', + description: 'Draft challenge payment', + externalId: 'challenge-id', + details: [ + { + totalAmount: 100, + grossAmount: 100, + installmentNumber: 1, + currency: PrizeType.USD, + billingAccount: '80001012', + }, + ], + } as any, + 'creator-1', + ); + + expect(topcoderChallengesService.getChallengeById).toHaveBeenCalledWith( + 'challenge-id', + ); + expect(tx.payment.findMany).toHaveBeenCalledWith({ + select: { total_amount: true }, + where: { + billing_account: '80001012', + currency: PrizeType.USD, + winnings: { + external_id: 'challenge-id', + type: 'PAYMENT', + }, + }, + }); + expect(billingAccountsService.lockConsumeAmount).toHaveBeenCalledWith({ + billingAccountId: 80001012, + challengeId: 'challenge-id', + markup: 0.2, + status: 'DRAFT', + totalPrizesInCents: 15025, + }); + }); + + it('skips per-winning billing sync for generated challenge payment batches', async () => { + await service.createWinningWithPayments( + { + winnerId: 'user-1', + type: WinningsType.PAYMENT, + origin: 'Topcoder', + category: WinningsCategory.CONTEST_PAYMENT, + title: 'Generated challenge payment', + description: 'Generated challenge payment', + externalId: 'challenge-id', + attributes: { + billingAccountId: '80001012', + [CHALLENGE_BUDGET_SYNC_SKIP_ATTRIBUTE]: true, + }, + details: [ + { + totalAmount: 100, + grossAmount: 100, + installmentNumber: 1, + currency: PrizeType.USD, + billingAccount: '80001012', + }, + ], + } as any, + 'creator-1', + ); + + expect(topcoderChallengesService.getChallengeById).not.toHaveBeenCalled(); + expect(billingAccountsService.lockConsumeAmount).not.toHaveBeenCalled(); + expect(tx.winnings.create.mock.calls[0][0].data.attributes).toEqual({ + billingAccountId: '80001012', + }); + }); }); diff --git a/src/api/winnings/winnings.service.ts b/src/api/winnings/winnings.service.ts index bb44062..2ede782 100644 --- a/src/api/winnings/winnings.service.ts +++ b/src/api/winnings/winnings.service.ts @@ -35,10 +35,15 @@ import { IdentityVerificationRepository } from '../repository/identity-verificat import { PrizeType } from '../challenges/models'; import { BillingAccountsService } from 'src/shared/topcoder/billing-accounts.service'; import { TopcoderEngagementsService } from 'src/shared/topcoder/engagements.service'; +import { + TopcoderChallengeInfo, + TopcoderChallengesService, +} from 'src/shared/topcoder/challenges.service'; import { TopcoderM2MHttpError } from 'src/shared/topcoder/topcoder-m2m.service'; const BUDGET_LEDGER_DECIMAL_PLACES = 4; const PAYMENT_DECIMAL_PLACES = 2; +export const CHALLENGE_BUDGET_SYNC_SKIP_ATTRIBUTE = 'skipChallengeBudgetSync'; interface EngagementBillingAccountConsume { amount: number; @@ -53,6 +58,17 @@ interface EngagementBillingAccountConsumePlan { consumes: EngagementBillingAccountConsume[]; } +interface ChallengeBillingAccountSync { + billingAccountId: number; + markup: number; +} + +interface ChallengeBillingAccountSyncPlan { + billingAccounts: ChallengeBillingAccountSync[]; + challengeId: string; + status: string; +} + /** * The winning service. */ @@ -69,6 +85,7 @@ export class WinningsService { * @param tcMembersService Topcoder member profile client. * @param identityVerificationRepo repository for identity verification checks. * @param tcEmailService Topcoder email client. + * @param topcoderChallengesService Topcoder challenge client. * @param topcoderEngagementsService Topcoder engagements client. * @param billingAccountsService Topcoder billing-account client. */ @@ -80,13 +97,15 @@ export class WinningsService { private readonly tcMembersService: TopcoderMembersService, private readonly identityVerificationRepo: IdentityVerificationRepository, private readonly tcEmailService: TopcoderEmailService, + private readonly topcoderChallengesService: TopcoderChallengesService, private readonly topcoderEngagementsService: TopcoderEngagementsService, private readonly billingAccountsService: BillingAccountsService, ) {} /** * Builds the persisted winning attributes object. - * @param attributes Arbitrary attributes supplied by the client. + * @param attributes Arbitrary attributes supplied by the client. The internal + * challenge budget sync skip marker is removed before persistence. * @param hoursWorked Optional engagement-payment hours to persist. * @returns The normalized attributes object, or `undefined` when empty. */ @@ -98,6 +117,7 @@ export class WinningsService { attributes && typeof attributes === 'object' && !Array.isArray(attributes) ? { ...attributes } : {}; + delete normalizedAttributes[CHALLENGE_BUDGET_SYNC_SKIP_ATTRIBUTE]; if (hoursWorked !== undefined) { normalizedAttributes.hoursWorked = hoursWorked; @@ -235,6 +255,239 @@ export class WinningsService { return parsedBillingAccountId; } + /** + * Checks whether the winning request opts out of per-winning challenge budget + * synchronization. + * + * @param body incoming winning creation request. + * @returns True when another caller will manage the aggregate challenge + * billing-account row. + */ + private shouldSkipChallengeBudgetSync( + body: WinningCreateRequestDto, + ): boolean { + const attributes = + body.attributes && + typeof body.attributes === 'object' && + !Array.isArray(body.attributes) + ? (body.attributes as Record) + : undefined; + + return attributes?.[CHALLENGE_BUDGET_SYNC_SKIP_ATTRIBUTE] === true; + } + + /** + * Normalizes the billing-account id from a challenge payment detail. + * + * @param detail payment detail supplied in the winning request. + * @param detailIndex zero-based detail index for error reporting. + * @returns positive integer billing account id. + * @throws BadRequestException when the detail cannot be mapped to an id. + */ + private normalizeChallengeBillingAccountId( + detail: PaymentCreateRequestDto, + detailIndex: number, + ): number { + const rawBillingAccount = String(detail.billingAccount ?? '').trim(); + const billingAccountId = Number(rawBillingAccount); + + if ( + !rawBillingAccount || + !/^\d+$/.test(rawBillingAccount) || + !Number.isSafeInteger(billingAccountId) || + billingAccountId <= 0 + ) { + throw new BadRequestException( + `details[${detailIndex}].billingAccount must be a valid billing account id`, + ); + } + + return billingAccountId; + } + + /** + * Finds the distinct billing accounts touched by USD challenge payment details. + * + * @param body incoming winning creation request. + * @returns Unique positive billing-account ids from USD payment details. + * @throws BadRequestException when a USD detail has an invalid billing account. + */ + private getChallengePaymentBillingAccountIds( + body: WinningCreateRequestDto, + ): number[] { + const billingAccountIds = new Set(); + + for (const [detailIndex, detail] of (body.details || []).entries()) { + if (String(detail.currency) !== String(PrizeType.USD)) { + continue; + } + + billingAccountIds.add( + this.normalizeChallengeBillingAccountId(detail, detailIndex), + ); + } + + return [...billingAccountIds]; + } + + /** + * Resolves the markup rate to use for a challenge billing-account sync. + * + * @param challenge challenge-api-v6 challenge details. + * @param billingAccountId billing account being synchronized. + * @returns Non-negative markup rate. + * @throws InternalServerErrorException when no valid markup can be resolved. + */ + private async resolveChallengeBillingMarkup( + challenge: TopcoderChallengeInfo, + billingAccountId: number, + ): Promise { + const candidateMarkups = [ + challenge.billing?.clientBillingRate, + challenge.billing?.markup, + ]; + + for (const candidateMarkup of candidateMarkups) { + const markup = Number(candidateMarkup); + + if (Number.isFinite(markup) && markup >= 0) { + return markup; + } + } + + const billingAccount = + await this.billingAccountsService.getBillingAccountById(billingAccountId); + + if (!Number.isFinite(billingAccount.markup) || billingAccount.markup < 0) { + throw new InternalServerErrorException( + `Billing account ${billingAccountId} has invalid markup`, + ); + } + + return billingAccount.markup; + } + + /** + * Builds the challenge budget synchronization plan for a winning request. + * + * Finance only synchronizes non-engagement USD payment requests that point to + * an existing challenge. Challenge-generated payment batches set an internal + * skip marker because they already write one aggregate budget row after all + * generated payments are attempted. + * + * @param body incoming winning creation request. + * @returns Billing-account sync plan, or `undefined` when the request is not + * a challenge payment. + * @throws BadRequestException when a challenge payment detail has an invalid + * billing account. + * @throws InternalServerErrorException when markup cannot be resolved. + */ + private async buildChallengeBillingAccountSyncPlan( + body: WinningCreateRequestDto, + ): Promise { + const challengeId = String(body.externalId ?? '').trim(); + + if ( + this.shouldSkipChallengeBudgetSync(body) || + body.category === WinningsCategory.ENGAGEMENT_PAYMENT || + body.type !== WinningsType.PAYMENT || + !challengeId + ) { + return undefined; + } + + const billingAccountIds = this.getChallengePaymentBillingAccountIds(body); + + if (billingAccountIds.length === 0) { + return undefined; + } + + const challenge = + await this.topcoderChallengesService.getChallengeById(challengeId); + + if (!challenge?.id || !challenge.status) { + return undefined; + } + + return { + billingAccounts: await Promise.all( + billingAccountIds.map(async (billingAccountId) => ({ + billingAccountId, + markup: await this.resolveChallengeBillingMarkup( + challenge, + billingAccountId, + ), + })), + ), + challengeId, + status: challenge.status, + }; + } + + /** + * Sums persisted USD payment rows for a challenge and billing account. + * + * @param tx active Prisma transaction. + * @param challengeId challenge external id stored on winnings. + * @param billingAccountId billing account stored on payment rows. + * @returns Payment-scale total USD amount for all persisted challenge + * payments on that billing account. + */ + private async getPersistedChallengePaymentTotal( + tx: Prisma.TransactionClient, + challengeId: string, + billingAccountId: number, + ): Promise { + const payments = await tx.payment.findMany({ + select: { total_amount: true }, + where: { + billing_account: String(billingAccountId), + currency: PrizeType.USD, + winnings: { + external_id: challengeId, + type: winnings_type.PAYMENT, + }, + }, + }); + const totalAmount = payments.reduce( + (sum, paymentRow) => + sum.plus(new Prisma.Decimal(paymentRow.total_amount ?? 0)), + new Prisma.Decimal(0), + ); + + return this.toPaymentAmount(totalAmount); + } + + /** + * Synchronizes challenge payment totals into billing-account locked/consumed rows. + * + * @param tx active Prisma transaction after the winning row has been created. + * @param syncPlan challenge billing-account sync plan. + * @returns Resolves when all affected billing accounts are synchronized. + */ + private async syncChallengeBillingAccountBudget( + tx: Prisma.TransactionClient, + syncPlan: ChallengeBillingAccountSyncPlan, + ): Promise { + await Promise.all( + syncPlan.billingAccounts.map(async ({ billingAccountId, markup }) => { + const totalUsdAmount = await this.getPersistedChallengePaymentTotal( + tx, + syncPlan.challengeId, + billingAccountId, + ); + + await this.billingAccountsService.lockConsumeAmount({ + billingAccountId, + challengeId: syncPlan.challengeId, + markup, + status: syncPlan.status, + totalPrizesInCents: totalUsdAmount * 100, + }); + }), + ); + } + /** * Resolves the assignment's billing account from trusted backend context. * @@ -751,13 +1004,16 @@ export class WinningsService { * Create winnings with parameters. Engagement payment requests derive * `payment.challenge_markup` and `payment.challenge_fee` from the trusted * project billing account and are gated by billing-account budget - * consumption before the transaction can complete. + * consumption before the transaction can complete. Challenge payment + * requests that point to a challenge synchronize the aggregate persisted USD + * payment total back to billing-account locked or consumed rows based on the + * current challenge status. * * @param body the request body * @param userId the request userId * @returns the Promise with response result - * @throws BadRequestException when engagement payment budget consume fails or - * engagement payment input is invalid. + * @throws BadRequestException when billing-account budget sync fails or + * payment input is invalid. */ async createWinningWithPayments( body: WinningCreateRequestDto, @@ -769,6 +1025,8 @@ export class WinningsService { const engagementConsumePlan = isEngagementPayment ? await this.buildEngagementBillingAccountConsumePlan(body) : undefined; + const challengeBillingAccountSyncPlan = + await this.buildChallengeBillingAccountSyncPlan(body); let setupEmailNotificationAmount: number | undefined; this.logger.debug( @@ -979,6 +1237,13 @@ export class WinningsService { await this.consumeEngagementBillingAccounts(engagementConsumePlan); } + if (challengeBillingAccountSyncPlan) { + await this.syncChallengeBillingAccountBudget( + tx, + challengeBillingAccountSyncPlan, + ); + } + if ( !isPointsAward && !payrollPayment && diff --git a/src/dto/winning.dto.ts b/src/dto/winning.dto.ts index c666929..0f9910b 100644 --- a/src/dto/winning.dto.ts +++ b/src/dto/winning.dto.ts @@ -173,7 +173,10 @@ export class WinningRequestDto extends SortPagination { description: 'Multiple winnings categories to filter by', enum: WinningsCategory, isArray: true, - example: [WinningsCategory.ENGAGEMENT_PAYMENT, WinningsCategory.TASK_PAYMENT], + example: [ + WinningsCategory.ENGAGEMENT_PAYMENT, + WinningsCategory.TASK_PAYMENT, + ], }) @IsOptional() @IsArray() diff --git a/src/shared/access-control/access-control.service.spec.ts b/src/shared/access-control/access-control.service.spec.ts index 85f8a80..44cc418 100644 --- a/src/shared/access-control/access-control.service.spec.ts +++ b/src/shared/access-control/access-control.service.spec.ts @@ -30,9 +30,7 @@ describe('AccessControlService', () => { roleName: Role.PaymentAdmin, applyFilter: paymentAdminApplyFilter, }; - const approverProvider: RoleAccessProvider< - Record - > = { + const approverProvider: RoleAccessProvider> = { roleName: Role.PaymentApprover, applyFilter: approverApplyFilter, }; @@ -63,9 +61,7 @@ describe('AccessControlService', () => { categories: ['ENGAGEMENT_PAYMENT', 'TASK_PAYMENT'], }), ); - const approverProvider: RoleAccessProvider< - Record - > = { + const approverProvider: RoleAccessProvider> = { roleName: Role.PaymentApprover, applyFilter: approverApplyFilter, }; diff --git a/src/shared/topcoder/billing-accounts.service.spec.ts b/src/shared/topcoder/billing-accounts.service.spec.ts new file mode 100644 index 0000000..cc3e42a --- /dev/null +++ b/src/shared/topcoder/billing-accounts.service.spec.ts @@ -0,0 +1,52 @@ +jest.mock('src/config', () => ({ + ENV_CONFIG: { + TGBillingAccounts: [], + TOPCODER_API_V6_BASE_URL: 'https://api.topcoder-dev.com/v6', + }, +})); + +jest.mock('src/shared/global', () => ({ + Logger: class { + error = jest.fn(); + + info = jest.fn(); + + log = jest.fn(); + + warn = jest.fn(); + }, +})); + +import { ChallengeStatuses } from 'src/dto/challenge.dto'; +import { BillingAccountsService } from './billing-accounts.service'; + +describe('BillingAccountsService', () => { + let service: BillingAccountsService; + + beforeEach(() => { + service = new BillingAccountsService({} as any); + }); + + it('locks draft challenge funds until the challenge is completed', async () => { + const consumeAmountSpy = jest + .spyOn(service, 'consumeAmount') + .mockResolvedValue(undefined); + const lockAmountSpy = jest + .spyOn(service, 'lockAmount') + .mockResolvedValue(undefined); + + await service.lockConsumeAmount({ + billingAccountId: 80001012, + challengeId: 'challenge-id', + markup: 0.2, + status: ChallengeStatuses.Draft, + totalPrizesInCents: 25000, + }); + + expect(lockAmountSpy).toHaveBeenCalledWith(80001012, { + amount: 300, + challengeId: 'challenge-id', + }); + expect(consumeAmountSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/topcoder/billing-accounts.service.ts b/src/shared/topcoder/billing-accounts.service.ts index dab2164..4b02c3e 100644 --- a/src/shared/topcoder/billing-accounts.service.ts +++ b/src/shared/topcoder/billing-accounts.service.ts @@ -10,6 +10,12 @@ import { const { TOPCODER_API_V6_BASE_URL, TGBillingAccounts } = ENV_CONFIG; +const LOCKED_CHALLENGE_STATUSES = [ + ChallengeStatuses.Draft, + ChallengeStatuses.Active, + ChallengeStatuses.Approved, +].map((status) => status.toLowerCase()); + interface LockAmountDTO { challengeId: string; amount: number; @@ -49,6 +55,19 @@ export interface BAValidation { totalPrizesInCents: number; } +/** + * Determines whether a challenge status should reserve billing-account budget + * without consuming it. + * + * @param status Challenge status received from challenge-api-v6. + * @returns True when finance should write the challenge amount as locked. + */ +function isLockedChallengeStatus(status?: string): boolean { + return status + ? LOCKED_CHALLENGE_STATUSES.includes(status.toLowerCase()) + : false; +} + @Injectable() export class BillingAccountsService { private readonly logger = new Logger(BillingAccountsService.name); @@ -329,10 +348,7 @@ export class BillingAccountsService { this.logger.log('BA validation:', baValidation); const status = baValidation.status?.toLowerCase(); - if ( - status === ChallengeStatuses.Active.toLowerCase() || - status === ChallengeStatuses.Approved.toLowerCase() - ) { + if (isLockedChallengeStatus(status)) { // Update lock amount const currAmount = baValidation.totalPrizesInCents / 100; const prevAmount = (baValidation.prevTotalPrizesInCents ?? 0) / 100; diff --git a/src/shared/topcoder/challenges.service.ts b/src/shared/topcoder/challenges.service.ts index 4e65884..3ac8bed 100644 --- a/src/shared/topcoder/challenges.service.ts +++ b/src/shared/topcoder/challenges.service.ts @@ -20,10 +20,16 @@ export interface AdminPaymentUpdateData { } export interface TopcoderChallengeInfo { + billing?: { + billingAccountId?: number | string | null; + clientBillingRate?: number | string | null; + markup?: number | string | null; + }; + createdBy?: string; id: string; name: string; projectId: number; - createdBy?: string; + status?: string; } export interface TopcoderProjectInfo { From 9fc2e4dff89db772c25373a5939306de37a244ba Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 28 Apr 2026 10:40:35 +1000 Subject: [PATCH 14/19] PM-4960: sync admin challenge payments to challenge BA What was broken Payments added to completed challenges from system admin could create finance payment rows without guaranteeing the challenge billing account was the account synchronized back to billing accounts. That left the work app BA popup at risk of missing the added payment in consumed budget. Root cause (if identifiable) The manual /winnings challenge-payment path used the billing account supplied in the payment detail as the sync target, instead of treating the challenge billing account as the source of truth. What was changed Manual challenge payments now validate caller-supplied USD payment details against the challenge billing account when challenge-api exposes one, then synchronize the aggregate challenge payment total against that challenge billing account. Existing fallback behavior remains for challenge payloads that do not expose billing metadata. Any added/updated tests Added WinningsService coverage for completed challenge payments syncing to consumed challenge BA budget and for rejecting manual challenge payments that target a different billing account. --- src/api/winnings/winnings.service.spec.ts | 96 ++++++++++++++++++ src/api/winnings/winnings.service.ts | 118 +++++++++++++++++++++- 2 files changed, 212 insertions(+), 2 deletions(-) diff --git a/src/api/winnings/winnings.service.spec.ts b/src/api/winnings/winnings.service.spec.ts index 32d161e..fb70eda 100644 --- a/src/api/winnings/winnings.service.spec.ts +++ b/src/api/winnings/winnings.service.spec.ts @@ -440,6 +440,102 @@ describe('WinningsService', () => { expect(Number(persistedPayment.challenge_fee)).toBe(20); }); + it('syncs completed challenge payments to the challenge billing account as consumed', async () => { + topcoderChallengesService.getChallengeById.mockResolvedValue({ + billing: { + billingAccountId: '80001012', + markup: 0.2, + }, + id: 'challenge-id', + status: 'COMPLETED', + }); + tx.payment.findMany.mockResolvedValue([ + { total_amount: '75.00' }, + { total_amount: '25.00' }, + ]); + + await service.createWinningWithPayments( + { + winnerId: 'user-1', + type: WinningsType.PAYMENT, + origin: 'Topcoder', + category: WinningsCategory.CONTEST_PAYMENT, + title: 'Completed challenge payment', + description: 'Completed challenge payment', + externalId: 'challenge-id', + details: [ + { + totalAmount: 25, + grossAmount: 25, + installmentNumber: 1, + currency: PrizeType.USD, + billingAccount: '80001012', + }, + ], + } as any, + 'creator-1', + ); + + expect(tx.payment.findMany).toHaveBeenCalledWith({ + select: { total_amount: true }, + where: { + billing_account: '80001012', + currency: PrizeType.USD, + winnings: { + external_id: 'challenge-id', + type: 'PAYMENT', + }, + }, + }); + expect(billingAccountsService.lockConsumeAmount).toHaveBeenCalledWith({ + billingAccountId: 80001012, + challengeId: 'challenge-id', + markup: 0.2, + status: 'COMPLETED', + totalPrizesInCents: 10000, + }); + }); + + it('rejects manual challenge payments for a different billing account', async () => { + topcoderChallengesService.getChallengeById.mockResolvedValue({ + billing: { + billingAccountId: '80001012', + markup: 0.2, + }, + id: 'challenge-id', + status: 'COMPLETED', + }); + + await expect( + service.createWinningWithPayments( + { + winnerId: 'user-1', + type: WinningsType.PAYMENT, + origin: 'Topcoder', + category: WinningsCategory.CONTEST_PAYMENT, + title: 'Completed challenge payment', + description: 'Completed challenge payment', + externalId: 'challenge-id', + details: [ + { + totalAmount: 25, + grossAmount: 25, + installmentNumber: 1, + currency: PrizeType.USD, + billingAccount: '999999', + }, + ], + } as any, + 'creator-1', + ), + ).rejects.toThrow( + 'details[0].billingAccount does not match the challenge billing account', + ); + + expect(tx.winnings.create).not.toHaveBeenCalled(); + expect(billingAccountsService.lockConsumeAmount).not.toHaveBeenCalled(); + }); + it('locks the aggregate billing-account amount for draft challenge payments', async () => { topcoderChallengesService.getChallengeById.mockResolvedValue({ billing: { diff --git a/src/api/winnings/winnings.service.ts b/src/api/winnings/winnings.service.ts index 2ede782..758cfb4 100644 --- a/src/api/winnings/winnings.service.ts +++ b/src/api/winnings/winnings.service.ts @@ -330,6 +330,113 @@ export class WinningsService { return [...billingAccountIds]; } + /** + * Normalizes the billing-account id configured on a challenge. + * + * @param billingAccountId raw challenge billing-account id. + * @param challengeId challenge id used in diagnostics. + * @returns positive integer billing-account id, or `undefined` when the + * challenge does not expose a configured billing account. + * @throws InternalServerErrorException when the configured billing-account id + * is malformed. + */ + private normalizeConfiguredChallengeBillingAccountId( + billingAccountId: unknown, + challengeId: string, + ): number | undefined { + if (billingAccountId === undefined || billingAccountId === null) { + return undefined; + } + + if ( + typeof billingAccountId !== 'string' && + typeof billingAccountId !== 'number' + ) { + throw new InternalServerErrorException( + `Challenge ${challengeId} billing account id has invalid type`, + ); + } + + const normalizedBillingAccountId = String(billingAccountId).trim(); + + if (!normalizedBillingAccountId) { + return undefined; + } + + if (!/^\d+$/.test(normalizedBillingAccountId)) { + throw new InternalServerErrorException( + `Challenge ${challengeId} billing account id is invalid`, + ); + } + + const parsedBillingAccountId = Number(normalizedBillingAccountId); + + if ( + !Number.isSafeInteger(parsedBillingAccountId) || + parsedBillingAccountId <= 0 + ) { + throw new InternalServerErrorException( + `Challenge ${challengeId} billing account id is invalid`, + ); + } + + return parsedBillingAccountId; + } + + /** + * Resolves the billing accounts that should be synchronized for a challenge + * payment request. + * + * When challenge-api exposes a challenge billing account, finance treats it + * as the source of truth and rejects caller-supplied payment details that + * target another account. Challenges without billing metadata keep the + * previous detail-based fallback. + * + * @param body incoming winning creation request. + * @param challenge challenge-api-v6 challenge details. + * @param detailBillingAccountIds billing-account ids supplied by USD payment + * details. + * @returns billing-account ids to synchronize for this challenge. + * @throws BadRequestException when a payment detail targets a different + * billing account than the challenge. + * @throws InternalServerErrorException when the challenge billing account id + * is malformed. + */ + private getChallengeBillingAccountSyncIds( + body: WinningCreateRequestDto, + challenge: TopcoderChallengeInfo, + detailBillingAccountIds: number[], + ): number[] { + const configuredBillingAccountId = + this.normalizeConfiguredChallengeBillingAccountId( + challenge.billing?.billingAccountId, + String(challenge.id), + ); + + if (configuredBillingAccountId === undefined) { + return detailBillingAccountIds; + } + + for (const [detailIndex, detail] of (body.details || []).entries()) { + if (String(detail.currency) !== String(PrizeType.USD)) { + continue; + } + + const suppliedBillingAccountId = this.normalizeChallengeBillingAccountId( + detail, + detailIndex, + ); + + if (suppliedBillingAccountId !== configuredBillingAccountId) { + throw new BadRequestException( + `details[${detailIndex}].billingAccount does not match the challenge billing account`, + ); + } + } + + return [configuredBillingAccountId]; + } + /** * Resolves the markup rate to use for a challenge billing-account sync. * @@ -379,7 +486,8 @@ export class WinningsService { * @returns Billing-account sync plan, or `undefined` when the request is not * a challenge payment. * @throws BadRequestException when a challenge payment detail has an invalid - * billing account. + * billing account. When the challenge exposes a billing account, that account + * is used as the source of truth for the sync. * @throws InternalServerErrorException when markup cannot be resolved. */ private async buildChallengeBillingAccountSyncPlan( @@ -409,9 +517,15 @@ export class WinningsService { return undefined; } + const billingAccountSyncIds = this.getChallengeBillingAccountSyncIds( + body, + challenge, + billingAccountIds, + ); + return { billingAccounts: await Promise.all( - billingAccountIds.map(async (billingAccountId) => ({ + billingAccountSyncIds.map(async (billingAccountId) => ({ billingAccountId, markup: await this.resolveChallengeBillingMarkup( challenge, From a903bab3f9571053d9487cf99bce48c718dbb9b0 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 28 Apr 2026 11:02:42 +1000 Subject: [PATCH 15/19] PM-4958: Sync BA budget after payment cancellation What was broken Cancelling a challenge payment in wallet-admin only changed the finance payment status. The billing account consumed line item for the challenge external ID still included the cancelled payment amount. Root cause (if identifiable) The admin update path did not recalculate or overwrite the challenge billing-account budget row after moving a payment to CANCELLED. The existing challenge budget total query also counted cancelled payment rows. What was changed Added challenge budget synchronization to the admin cancellation flow so BA receives the remaining non-cancelled USD payment total for the affected challenge and billing account. Updated challenge budget total queries to exclude CANCELLED payments. Any added/updated tests Added an AdminService cancellation test covering BA resync after a challenge payment is cancelled. Updated the WinningsService budget sync expectation to exclude CANCELLED rows. --- src/api/admin/admin.service.spec.ts | 122 +++++++++- src/api/admin/admin.service.ts | 268 +++++++++++++++++++++- src/api/winnings/winnings.service.spec.ts | 1 + src/api/winnings/winnings.service.ts | 8 +- 4 files changed, 390 insertions(+), 9 deletions(-) diff --git a/src/api/admin/admin.service.spec.ts b/src/api/admin/admin.service.spec.ts index 6e670b1..b69ae53 100644 --- a/src/api/admin/admin.service.spec.ts +++ b/src/api/admin/admin.service.spec.ts @@ -45,19 +45,36 @@ jest.mock('src/shared/topcoder/challenges.service', () => ({ })); 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; + }; let accessControlService: { verifyAccess: jest.Mock; }; @@ -75,15 +92,35 @@ describe('AdminService', () => { 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), + }; accessControlService = { verifyAccess: jest.fn().mockResolvedValue(undefined), }; @@ -343,6 +380,83 @@ describe('AdminService', () => { ).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('returns task details for task payment with projectId and approver', async () => { prisma.winnings.findFirst.mockResolvedValue({ winning_id: 'winning-task', diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 8f54306..4f9db84 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -6,13 +6,19 @@ import { UnauthorizedException, } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { + Prisma, + payment_status, + winnings_category, + winnings_type, +} from '@prisma/client'; import { PrismaService } from 'src/shared/global/prisma.service'; import { PaymentsService } from 'src/shared/payments'; import { AccessControlService } from 'src/shared/access-control/access-control.service'; import { ResponseDto } from 'src/dto/api-response.dto'; import { PaymentStatus } from 'src/dto/payment.dto'; +import { PrizeType } from '../challenges/models'; import { WinningAuditDto, AuditPayoutDto } from './dto/audit.dto'; import { WinningUpdateRequestDto } from './dto/winnings.dto'; import { Logger } from 'src/shared/global'; @@ -23,9 +29,19 @@ import { TopcoderEngagementsService, } from 'src/shared/topcoder/engagements.service'; import { TopcoderMembersService } from 'src/shared/topcoder/members.service'; -import { TopcoderChallengesService } from 'src/shared/topcoder/challenges.service'; +import { + TopcoderChallengeInfo, + TopcoderChallengesService, +} from 'src/shared/topcoder/challenges.service'; import { WinningPaymentDetailsDto } from './dto/payment-details.dto'; +const PAYMENT_DECIMAL_PLACES = 2; + +interface ChallengeBudgetSyncTarget { + billingAccountId: number; + challengeId: string; +} + /** * The admin winning service. */ @@ -291,6 +307,246 @@ export class AdminService { }); } + /** + * Normalizes billing-account identifiers read from persisted payment or + * challenge records. + * + * @param billingAccountId raw billing-account id value. + * @returns positive integer billing-account id, or `undefined` when the value + * is missing or malformed. + * @throws This helper does not throw. + */ + private normalizeBillingAccountId( + billingAccountId: unknown, + ): number | undefined { + if (billingAccountId === undefined || billingAccountId === null) { + return undefined; + } + + if ( + typeof billingAccountId !== 'string' && + typeof billingAccountId !== 'number' + ) { + return undefined; + } + + const normalizedBillingAccountId = String(billingAccountId).trim(); + const parsedBillingAccountId = Number(normalizedBillingAccountId); + + if ( + !normalizedBillingAccountId || + !/^\d+$/.test(normalizedBillingAccountId) || + !Number.isSafeInteger(parsedBillingAccountId) || + parsedBillingAccountId <= 0 + ) { + return undefined; + } + + return parsedBillingAccountId; + } + + /** + * Finds challenge billing-account rows that need to be recalculated after a + * wallet-admin payment status change. + * + * @param payments payment rows selected by the update request. + * @returns unique challenge and billing-account pairs touched by USD + * challenge payments. + * @throws This helper does not throw. + */ + private getChallengeBudgetSyncTargets( + payments: Awaited>, + ): ChallengeBudgetSyncTarget[] { + const targets = new Map(); + + payments.forEach((payment) => { + const challengeId = + typeof payment.winnings.external_id === 'string' + ? payment.winnings.external_id.trim() + : ''; + + if ( + !challengeId || + payment.winnings.type !== winnings_type.PAYMENT || + payment.winnings.category === winnings_category.ENGAGEMENT_PAYMENT || + (payment.currency ?? '') !== 'USD' + ) { + return; + } + + const billingAccountId = this.normalizeBillingAccountId( + payment.billing_account, + ); + + if (!billingAccountId) { + this.logger.warn( + `Skipping challenge budget sync for payment ${payment.payment_id}; invalid billing account ${String(payment.billing_account)}`, + ); + return; + } + + targets.set(`${challengeId}:${billingAccountId}`, { + billingAccountId, + challengeId, + }); + }); + + return [...targets.values()]; + } + + /** + * Resolves the billing account to synchronize for a challenge. + * + * @param challenge challenge-api-v6 payload. + * @param fallbackBillingAccountId billing account from the payment row when + * challenge metadata does not expose one. + * @returns the configured challenge billing account when available, otherwise + * the payment-row billing account. + * @throws This helper does not throw. + */ + private resolveChallengeBudgetBillingAccountId( + challenge: TopcoderChallengeInfo, + fallbackBillingAccountId: number, + ): number { + return ( + this.normalizeBillingAccountId(challenge.billing?.billingAccountId) ?? + fallbackBillingAccountId + ); + } + + /** + * Resolves the markup used when writing a challenge budget line item. + * + * @param challenge challenge-api-v6 payload. + * @param billingAccountId billing account being synchronized. + * @returns non-negative markup rate. + * @throws Error when no valid markup can be resolved. + */ + private async resolveChallengeBudgetMarkup( + challenge: TopcoderChallengeInfo, + billingAccountId: number, + ): Promise { + const candidateMarkups = [ + challenge.billing?.clientBillingRate, + challenge.billing?.markup, + ]; + + for (const candidateMarkup of candidateMarkups) { + const markup = Number(candidateMarkup); + + if (Number.isFinite(markup) && markup >= 0) { + return markup; + } + } + + const billingAccount = + await this.baService.getBillingAccountById(billingAccountId); + + if (!Number.isFinite(billingAccount.markup) || billingAccount.markup < 0) { + throw new Error(`Billing account ${billingAccountId} has invalid markup`); + } + + return billingAccount.markup; + } + + /** + * Quantizes a payment total to the same two-decimal scale used by persisted + * payment rows. + * + * @param amount decimal amount to normalize. + * @returns JavaScript number rounded to two decimal places. + * @throws This helper does not throw. + */ + private toPaymentAmount(amount: Prisma.Decimal): number { + return Number( + amount + .toDecimalPlaces(PAYMENT_DECIMAL_PLACES, Prisma.Decimal.ROUND_HALF_UP) + .toFixed(PAYMENT_DECIMAL_PLACES), + ); + } + + /** + * Sums non-cancelled USD payment rows for a challenge and billing account. + * + * @param challengeId challenge external id stored on winnings. + * @param billingAccountId billing account stored on payment rows. + * @returns total member-payment amount that should remain locked or consumed + * for the challenge billing-account line item. + * @throws Prisma errors when the aggregate query fails. + */ + private async getActiveChallengePaymentTotal( + challengeId: string, + billingAccountId: number, + ): Promise { + const payments = await this.prisma.payment.findMany({ + select: { total_amount: true }, + where: { + billing_account: String(billingAccountId), + currency: PrizeType.USD, + payment_status: { not: payment_status.CANCELLED }, + winnings: { + external_id: challengeId, + type: 'PAYMENT', + }, + }, + }); + const totalAmount = payments.reduce( + (sum, paymentRow) => + sum.plus(new Prisma.Decimal(paymentRow.total_amount ?? 0)), + new Prisma.Decimal(0), + ); + + return this.toPaymentAmount(totalAmount); + } + + /** + * Rewrites challenge billing-account budget rows after a wallet-admin status + * update changes the active payment total. + * + * @param targets unique challenge and billing-account pairs to synchronize. + * @returns promise resolved after every target has been sent to BA. + * @throws Error when BA synchronization fails. + */ + private async syncChallengeBudgetTargets( + targets: ChallengeBudgetSyncTarget[], + ): Promise { + await Promise.all( + targets.map(async (target) => { + const challenge = await this.topcoderChallengesService.getChallengeById( + target.challengeId, + ); + + if (!challenge?.id || !challenge.status) { + this.logger.warn( + `Skipping challenge budget sync for ${target.challengeId}; challenge metadata is unavailable`, + ); + return; + } + + const billingAccountId = this.resolveChallengeBudgetBillingAccountId( + challenge, + target.billingAccountId, + ); + const markup = await this.resolveChallengeBudgetMarkup( + challenge, + billingAccountId, + ); + const totalUsdAmount = await this.getActiveChallengePaymentTotal( + target.challengeId, + billingAccountId, + ); + + await this.baService.lockConsumeAmount({ + billingAccountId, + challengeId: target.challengeId, + markup, + status: challenge.status, + totalPrizesInCents: totalUsdAmount * 100, + }); + }), + ); + } + /** * Verify that a BA admin user has access to the billing account(s) * associated with the given winningsId. Throws BadRequestException when @@ -382,6 +638,10 @@ export class AdminService { tx: Prisma.TransactionClient, ) => Promise)[] = []; const now = new Date().getTime(); + const challengeBudgetSyncTargets = + body.paymentStatus === PaymentStatus.CANCELLED + ? this.getChallengeBudgetSyncTargets(payments) + : []; // iterate payments and build transaction list payments.forEach((payment) => { @@ -664,6 +924,10 @@ export class AdminService { `Successfully executed transactions for winningsId=${winningsId}`, ); + if (challengeBudgetSyncTargets.length > 0) { + await this.syncChallengeBudgetTargets(challengeBudgetSyncTargets); + } + if (needsReconciliation) { const winning = await this.prisma.winnings.findFirst({ select: { diff --git a/src/api/winnings/winnings.service.spec.ts b/src/api/winnings/winnings.service.spec.ts index 32d161e..3cc2611 100644 --- a/src/api/winnings/winnings.service.spec.ts +++ b/src/api/winnings/winnings.service.spec.ts @@ -483,6 +483,7 @@ describe('WinningsService', () => { where: { billing_account: '80001012', currency: PrizeType.USD, + payment_status: { not: 'CANCELLED' }, winnings: { external_id: 'challenge-id', type: 'PAYMENT', diff --git a/src/api/winnings/winnings.service.ts b/src/api/winnings/winnings.service.ts index 2ede782..e20531a 100644 --- a/src/api/winnings/winnings.service.ts +++ b/src/api/winnings/winnings.service.ts @@ -425,13 +425,14 @@ export class WinningsService { } /** - * Sums persisted USD payment rows for a challenge and billing account. + * Sums non-cancelled persisted USD payment rows for a challenge and billing + * account. * * @param tx active Prisma transaction. * @param challengeId challenge external id stored on winnings. * @param billingAccountId billing account stored on payment rows. - * @returns Payment-scale total USD amount for all persisted challenge - * payments on that billing account. + * @returns Payment-scale total USD amount for active challenge payments on + * that billing account. */ private async getPersistedChallengePaymentTotal( tx: Prisma.TransactionClient, @@ -443,6 +444,7 @@ export class WinningsService { where: { billing_account: String(billingAccountId), currency: PrizeType.USD, + payment_status: { not: payment_status.CANCELLED }, winnings: { external_id: challengeId, type: winnings_type.PAYMENT, From 323ec72b66900924d25197257a6b153964a9c4c6 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 28 Apr 2026 11:14:22 +1000 Subject: [PATCH 16/19] PM-4957: Fix cancelled challenge budget sync What was broken Cancelled challenge payment generation only treated CANCELLED_FAILED_REVIEW as payable, and cancelled budget sync only unlocked prior active locks. Cancelled challenges with reviewer payments could leave budget locked instead of consumed, while cancelled challenges with no payments did not reliably clear the lock. Root cause Finance keyed cancelled budget handling on the previous ACTIVE status instead of the aggregate generated payment total, and challenge payment generation did not accept all cancelled terminal statuses. What was changed Allowed cancelled challenge statuses through payment generation, while skipping winner and copilot payments so cancelled challenges only generate eligible reviewer payments. Updated billing-account synchronization so cancelled challenges with generated payment totals consume the budget, while cancelled challenges with a zero generated total unlock the budget. Any added/updated tests Added challenge service tests for cancelled winner and copilot skips plus zero-payment lock release. Added billing account service tests for cancelled generated-payment consumption and zero-payment unlock behavior. --- src/api/challenges/challenges.service.spec.ts | 108 ++++++++++++++++++ src/api/challenges/challenges.service.ts | 47 +++++--- .../topcoder/billing-accounts.service.spec.ts | 46 ++++++++ .../topcoder/billing-accounts.service.ts | 68 ++++++----- 4 files changed, 226 insertions(+), 43 deletions(-) diff --git a/src/api/challenges/challenges.service.spec.ts b/src/api/challenges/challenges.service.spec.ts index 0318e91..ac8a42c 100644 --- a/src/api/challenges/challenges.service.spec.ts +++ b/src/api/challenges/challenges.service.spec.ts @@ -254,4 +254,112 @@ describe('ChallengesService', () => { }), ]); }); + + it('skips winner payments for cancelled challenges', () => { + const service = new ChallengesService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + + const payments = service.generateWinnersPayments( + { + name: 'Cancelled Challenge', + status: ChallengeStatuses.CancelledClientRequest, + task: { isTask: false }, + type: 'Challenge', + } as any, + [{ handle: 'winner', placement: 1, userId: 40158994 }], + [{ type: PrizeType.USD, value: 500 }], + ); + + expect(payments).toEqual([]); + }); + + it('skips copilot payments for cancelled challenges', () => { + const service = new ChallengesService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + + const payments = service.generateCopilotPayment( + { + id: '11111111-1111-1111-1111-111111111111', + name: 'Cancelled Challenge', + prizeSets: [ + { prizes: [{ type: PrizeType.USD, value: 100 }], type: 'COPILOT' }, + { prizes: [{ type: PrizeType.USD, value: 500 }], type: 'PLACEMENT' }, + ], + status: ChallengeStatuses.CancelledClientRequest, + } as any, + [{ memberHandle: 'copilot', memberId: '40158994' }] as any, + ); + + expect(payments).toEqual([]); + }); + + it('allows cancelled challenges with no generated payments to release budget locks', async () => { + const prisma = { + challenge_lock: { + create: jest.fn().mockResolvedValue({}), + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }; + const baService = { + lockConsumeAmount: jest.fn().mockResolvedValue(undefined), + }; + const winningsService = { + createWinningWithPayments: jest.fn(), + }; + const winningsRepo = { + searchWinnings: jest.fn().mockResolvedValue({ data: { winnings: [] } }), + }; + const service = new ChallengesService( + prisma as any, + {} as any, + baService as any, + winningsService as any, + winningsRepo as any, + ); + const challenge = { + billing: { billingAccountId: '80001012', markup: 0.1 }, + funChallenge: false, + id: '11111111-1111-1111-1111-111111111111', + name: 'Cancelled Challenge', + prizeSets: [ + { prizes: [{ type: PrizeType.USD, value: 500 }], type: 'PLACEMENT' }, + ], + reviewers: [], + status: ChallengeStatuses.CancelledClientRequest, + task: { isTask: false }, + type: 'Challenge', + }; + + jest.spyOn(service, 'getChallenge').mockResolvedValue(challenge as any); + jest.spyOn(service, 'getChallengeResources').mockResolvedValue({ + reviewer: [{ memberHandle: 'reviewer', memberId: '40158995' }], + } as any); + jest.spyOn(service, 'getChallengeReviews').mockResolvedValue([]); + + await service.generateChallengePayments( + '11111111-1111-1111-1111-111111111111', + 'test-user', + ); + + expect(winningsService.createWinningWithPayments).not.toHaveBeenCalled(); + expect(baService.lockConsumeAmount).toHaveBeenCalledWith( + expect.objectContaining({ + billingAccountId: 80001012, + challengeId: '11111111-1111-1111-1111-111111111111', + markup: 0.1, + status: ChallengeStatuses.CancelledClientRequest, + totalPrizesInCents: 0, + }), + ); + }); }); diff --git a/src/api/challenges/challenges.service.ts b/src/api/challenges/challenges.service.ts index b884034..feb419e 100644 --- a/src/api/challenges/challenges.service.ts +++ b/src/api/challenges/challenges.service.ts @@ -64,8 +64,33 @@ const PAYMENT_TYPE_TO_CATEGORY: Record = { topgear: WinningsCategory.TOPGEAR_PAYMENT, }; +const CANCELLED_CHALLENGE_STATUSES = [ + ChallengeStatuses.Canceled, + ChallengeStatuses.CancelledFailedReview, + ChallengeStatuses.CancelledFailedScreening, + ChallengeStatuses.CancelledZeroSubmissions, + ChallengeStatuses.CancelledWinnerUnresponsive, + ChallengeStatuses.CancelledClientRequest, + ChallengeStatuses.CancelledRequirementsInfeasible, + ChallengeStatuses.CancelledZeroRegistrations, + ChallengeStatuses.CancelledPaymentFailed, +].map((status) => status.toLowerCase()); + const { TOPCODER_API_V6_BASE_URL: TC_API_BASE, TGBillingAccounts } = ENV_CONFIG; +/** + * Determines whether a challenge status represents a cancelled challenge. + * + * @param status Challenge status returned by challenge-api-v6. + * @returns True when the status is one of the cancelled challenge states used + * by challenge-api-v6. + */ +function isCancelledChallengeStatus(status?: string): boolean { + return status + ? CANCELLED_CHALLENGE_STATUSES.includes(status.toLowerCase()) + : false; +} + @Injectable() export class ChallengesService { private readonly logger = new Logger(ChallengesService.name); @@ -194,11 +219,7 @@ export class ChallengesService { prizes: Prize[], type?: WinningsCategory, ): PaymentPayload[] { - const isCancelledFailedReview = - challenge.status.toLowerCase() === - ChallengeStatuses.CancelledFailedReview.toLowerCase(); - - if (isCancelledFailedReview) { + if (isCancelledChallengeStatus(challenge.status)) { return []; } @@ -285,14 +306,10 @@ export class ChallengesService { challenge: Challenge, copilots: ChallengeResource[], ): PaymentPayload[] { - const isCancelledFailedReview = - challenge.status.toLowerCase() === - ChallengeStatuses.CancelledFailedReview.toLowerCase(); - const copilotPrizes = find(challenge.prizeSets, { type: 'COPILOT' })?.prizes ?? []; - if (!copilotPrizes.length || isCancelledFailedReview) { + if (!copilotPrizes.length || isCancelledChallengeStatus(challenge.status)) { return []; } @@ -625,12 +642,12 @@ export class ChallengesService { `Challenge ${challenge.id} - "${challenge.name}" with status "${challenge.status}" retrieved`, ); - const allowedStatuses = [ - ChallengeStatuses.Completed.toLowerCase(), - ChallengeStatuses.CancelledFailedReview.toLowerCase(), - ]; + const isPayableStatus = + challenge.status.toLowerCase() === + ChallengeStatuses.Completed.toLowerCase() || + isCancelledChallengeStatus(challenge.status); - if (!allowedStatuses.includes(challenge.status.toLowerCase())) { + if (!isPayableStatus) { this.logger.error( `Challenge ${challenge.id} isn't in a payable status: ${challenge.status}`, ); diff --git a/src/shared/topcoder/billing-accounts.service.spec.ts b/src/shared/topcoder/billing-accounts.service.spec.ts index cc3e42a..2ec7eb7 100644 --- a/src/shared/topcoder/billing-accounts.service.spec.ts +++ b/src/shared/topcoder/billing-accounts.service.spec.ts @@ -49,4 +49,50 @@ describe('BillingAccountsService', () => { }); expect(consumeAmountSpy).not.toHaveBeenCalled(); }); + + it('consumes cancelled challenge funds when generated payments exist', async () => { + const consumeAmountSpy = jest + .spyOn(service, 'consumeAmount') + .mockResolvedValue(undefined); + const lockAmountSpy = jest + .spyOn(service, 'lockAmount') + .mockResolvedValue(undefined); + + await service.lockConsumeAmount({ + billingAccountId: 80001012, + challengeId: 'challenge-id', + markup: 0.2, + status: ChallengeStatuses.CancelledFailedReview, + totalPrizesInCents: 25000, + }); + + expect(consumeAmountSpy).toHaveBeenCalledWith(80001012, { + amount: 300, + challengeId: 'challenge-id', + }); + expect(lockAmountSpy).not.toHaveBeenCalled(); + }); + + it('unlocks cancelled challenge funds when no payments exist', async () => { + const consumeAmountSpy = jest + .spyOn(service, 'consumeAmount') + .mockResolvedValue(undefined); + const lockAmountSpy = jest + .spyOn(service, 'lockAmount') + .mockResolvedValue(undefined); + + await service.lockConsumeAmount({ + billingAccountId: 80001012, + challengeId: 'challenge-id', + markup: 0.2, + status: ChallengeStatuses.CancelledClientRequest, + totalPrizesInCents: 0, + }); + + expect(lockAmountSpy).toHaveBeenCalledWith(80001012, { + amount: 0, + challengeId: 'challenge-id', + }); + expect(consumeAmountSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/shared/topcoder/billing-accounts.service.ts b/src/shared/topcoder/billing-accounts.service.ts index 4b02c3e..9e48f50 100644 --- a/src/shared/topcoder/billing-accounts.service.ts +++ b/src/shared/topcoder/billing-accounts.service.ts @@ -16,6 +16,19 @@ const LOCKED_CHALLENGE_STATUSES = [ ChallengeStatuses.Approved, ].map((status) => status.toLowerCase()); +const CANCELLED_CHALLENGE_STATUSES = [ + ChallengeStatuses.Deleted, + ChallengeStatuses.Canceled, + ChallengeStatuses.CancelledFailedReview, + ChallengeStatuses.CancelledFailedScreening, + ChallengeStatuses.CancelledZeroSubmissions, + ChallengeStatuses.CancelledWinnerUnresponsive, + ChallengeStatuses.CancelledClientRequest, + ChallengeStatuses.CancelledRequirementsInfeasible, + ChallengeStatuses.CancelledZeroRegistrations, + ChallengeStatuses.CancelledPaymentFailed, +].map((status) => status.toLowerCase()); + interface LockAmountDTO { challengeId: string; amount: number; @@ -68,6 +81,18 @@ function isLockedChallengeStatus(status?: string): boolean { : false; } +/** + * Determines whether a challenge status represents a cancelled terminal state. + * + * @param status Challenge status received from challenge-api-v6. + * @returns True when finance should release or consume any challenge budget row. + */ +function isCancelledChallengeStatus(status?: string): boolean { + return status + ? CANCELLED_CHALLENGE_STATUSES.includes(status.toLowerCase()) + : false; +} + @Injectable() export class BillingAccountsService { private readonly logger = new Logger(BillingAccountsService.name); @@ -373,34 +398,21 @@ export class BillingAccountsService { (rollback ? prevAmount : currAmount) * (1 + baValidation.markup!), }); } - } else if ( - [ - ChallengeStatuses.Deleted, - ChallengeStatuses.Canceled, - ChallengeStatuses.CancelledFailedReview, - ChallengeStatuses.CancelledFailedScreening, - ChallengeStatuses.CancelledZeroSubmissions, - ChallengeStatuses.CancelledWinnerUnresponsive, - ChallengeStatuses.CancelledClientRequest, - ChallengeStatuses.CancelledRequirementsInfeasible, - ChallengeStatuses.CancelledZeroRegistrations, - ChallengeStatuses.CancelledPaymentFailed, - ].some((t) => t.toLowerCase() === status) - ) { - if ( - baValidation.prevStatus?.toLowerCase() === - ChallengeStatuses.Active.toLowerCase() - ) { - // Challenge canceled, unlock previous locked amount - const currAmount = 0; - const prevAmount = (baValidation.prevTotalPrizesInCents ?? 0) / 100; - - if (currAmount !== prevAmount) { - await this.lockAmount(billingAccountId, { - challengeId: baValidation.challengeId!, - amount: rollback ? prevAmount : 0, - }); - } + } else if (isCancelledChallengeStatus(status)) { + const currAmount = baValidation.totalPrizesInCents / 100; + const prevAmount = (baValidation.prevTotalPrizesInCents ?? 0) / 100; + const targetAmount = rollback ? prevAmount : currAmount; + + if (targetAmount > 0) { + await this.consumeAmount(billingAccountId, { + challengeId: baValidation.challengeId!, + amount: targetAmount * (1 + baValidation.markup!), + }); + } else { + await this.lockAmount(billingAccountId, { + challengeId: baValidation.challengeId!, + amount: 0, + }); } } } From 9cad172ab816a95fc6fc4fce99ff296d0c4bdd1a Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 28 Apr 2026 13:03:46 +1000 Subject: [PATCH 17/19] PM-4900: Backfill engagement BA consumed fees What was broken Existing engagement payment backfill only populated finance payment challenge markup and challenge fee values. The billing-account consumed ledger rows that Work reads for BA line items could still contain only the member payment amount, so engagement payments did not include the challenge fee in BA consumed amounts. Root cause (if identifiable) The runtime engagement payment path now sends member payment plus challenge fee to billing-accounts-api-v6, but the historical backfill script stopped at the finance payment table and did not update matching billing-accounts ConsumedAmount rows. What was changed Extended the engagement challenge-fee backfill script to match finance engagement payment rows to billing-account consumed rows by assignment id, billing account, and creation order, then update consumed ledger amounts to total amount plus derived challenge fee. Added pre-update counts and post-update sample output for verification. Aligned an existing winnings service spec expectation with the current cancelled-payment filter used when syncing completed challenge payments. Any added/updated tests Updated the existing winnings service test expectation for the completed challenge payment billing-account sync query. No automated SQL backfill test was added because the script spans the finance and billing-accounts schemas directly. --- src/api/winnings/winnings.service.spec.ts | 1 + ...gagement-payment-challenge-markup-fees.sql | 165 +++++++++++++++++- 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/src/api/winnings/winnings.service.spec.ts b/src/api/winnings/winnings.service.spec.ts index bb3c8b2..e8ea82a 100644 --- a/src/api/winnings/winnings.service.spec.ts +++ b/src/api/winnings/winnings.service.spec.ts @@ -481,6 +481,7 @@ describe('WinningsService', () => { where: { billing_account: '80001012', currency: PrizeType.USD, + payment_status: { not: 'CANCELLED' }, winnings: { external_id: 'challenge-id', type: 'PAYMENT', diff --git a/src/scripts/backfill-engagement-payment-challenge-markup-fees.sql b/src/scripts/backfill-engagement-payment-challenge-markup-fees.sql index b62f50c..923c2ab 100644 --- a/src/scripts/backfill-engagement-payment-challenge-markup-fees.sql +++ b/src/scripts/backfill-engagement-payment-challenge-markup-fees.sql @@ -8,11 +8,18 @@ -- BillingAccount.id. -- - Rows where payment.challenge_markup or payment.challenge_fee differs -- from the value derived from billing account markup and payment total_amount. +-- - Matching billing-accounts ConsumedAmount rows for engagement payments +-- whose amount does not include the derived challenge fee. -- -- Calculation: -- - payment.challenge_markup = billing account markup, rounded to the -- payment column scale. -- - payment.challenge_fee = payment.total_amount * payment.challenge_markup. +-- - "billing-accounts"."ConsumedAmount".amount = payment.total_amount + +-- payment.challenge_fee, rounded to the billing ledger scale. +-- - Finance payment rows are matched to billing ledger rows by assignment id, +-- billing account, and creation order because the billing API stores +-- engagement consumed rows without the finance payment id. -- -- Run this against the PostgreSQL database that contains these schemas: -- - "finance" @@ -146,6 +153,157 @@ SELECT COUNT(DISTINCT billing_account) AS "billingAccountsAffected" FROM updated; +WITH finance_payment_rows AS ( + SELECT + p.payment_id, + w.external_id AS "assignmentId", + p.billing_account AS "billingAccount", + ROW_NUMBER() OVER ( + PARTITION BY w.external_id, p.billing_account + ORDER BY + COALESCE(p.created_at, w.created_at), + p.payment_id + ) AS "rowNumber", + ROUND( + ( + p.total_amount + + ROUND(p.total_amount * ROUND(ba.markup::numeric, 2), 2) + )::numeric, + 4 + ) AS "expectedConsumedAmount" + FROM "finance"."payment" p + INNER JOIN "finance"."winnings" w + ON w.winning_id = p.winnings_id + INNER JOIN "billing-accounts"."BillingAccount" ba + ON p.billing_account ~ '^\d+$' + AND ba.id::text = p.billing_account + WHERE w.category::text = 'ENGAGEMENT_PAYMENT' + AND w.external_id IS NOT NULL + AND p.total_amount IS NOT NULL +), +billing_consumed_rows AS ( + SELECT + consumed_amount.id, + consumed_amount."externalId" AS "assignmentId", + consumed_amount."billingAccountId"::text AS "billingAccount", + ROW_NUMBER() OVER ( + PARTITION BY + consumed_amount."externalId", + consumed_amount."billingAccountId" + ORDER BY + consumed_amount."createdAt", + consumed_amount.id + ) AS "rowNumber", + consumed_amount.amount AS "currentConsumedAmount" + FROM "billing-accounts"."ConsumedAmount" consumed_amount + WHERE consumed_amount."externalType"::text = 'ENGAGEMENT' + AND EXISTS ( + SELECT 1 + FROM "finance"."payment" p + INNER JOIN "finance"."winnings" w + ON w.winning_id = p.winnings_id + WHERE w.category::text = 'ENGAGEMENT_PAYMENT' + AND w.external_id = consumed_amount."externalId" + AND p.billing_account = consumed_amount."billingAccountId"::text + ) +) +SELECT + COUNT(*) FILTER ( + WHERE finance_payment_rows.payment_id IS NOT NULL + AND billing_consumed_rows.id IS NULL + ) AS "financeRowsWithoutBillingConsumedRow", + COUNT(*) FILTER ( + WHERE finance_payment_rows.payment_id IS NULL + AND billing_consumed_rows.id IS NOT NULL + ) AS "billingConsumedRowsWithoutFinancePayment", + COUNT(*) FILTER ( + WHERE finance_payment_rows.payment_id IS NOT NULL + AND billing_consumed_rows.id IS NOT NULL + AND billing_consumed_rows."currentConsumedAmount" IS DISTINCT FROM + finance_payment_rows."expectedConsumedAmount" + ) AS "billingConsumedRowsToUpdate" +FROM finance_payment_rows +FULL OUTER JOIN billing_consumed_rows + ON billing_consumed_rows."assignmentId" = finance_payment_rows."assignmentId" + AND billing_consumed_rows."billingAccount" = finance_payment_rows."billingAccount" + AND billing_consumed_rows."rowNumber" = finance_payment_rows."rowNumber"; + +WITH finance_payment_rows AS ( + SELECT + p.payment_id, + w.external_id AS "assignmentId", + p.billing_account AS "billingAccount", + ROW_NUMBER() OVER ( + PARTITION BY w.external_id, p.billing_account + ORDER BY + COALESCE(p.created_at, w.created_at), + p.payment_id + ) AS "rowNumber", + ROUND( + ( + p.total_amount + + ROUND(p.total_amount * ROUND(ba.markup::numeric, 2), 2) + )::numeric, + 4 + ) AS "expectedConsumedAmount" + FROM "finance"."payment" p + INNER JOIN "finance"."winnings" w + ON w.winning_id = p.winnings_id + INNER JOIN "billing-accounts"."BillingAccount" ba + ON p.billing_account ~ '^\d+$' + AND ba.id::text = p.billing_account + WHERE w.category::text = 'ENGAGEMENT_PAYMENT' + AND w.external_id IS NOT NULL + AND p.total_amount IS NOT NULL +), +billing_consumed_rows AS ( + SELECT + consumed_amount.id, + consumed_amount."externalId" AS "assignmentId", + consumed_amount."billingAccountId"::text AS "billingAccount", + ROW_NUMBER() OVER ( + PARTITION BY + consumed_amount."externalId", + consumed_amount."billingAccountId" + ORDER BY + consumed_amount."createdAt", + consumed_amount.id + ) AS "rowNumber", + consumed_amount.amount AS "currentConsumedAmount" + FROM "billing-accounts"."ConsumedAmount" consumed_amount + WHERE consumed_amount."externalType"::text = 'ENGAGEMENT' +), +matched_rows AS ( + SELECT + billing_consumed_rows.id, + finance_payment_rows."expectedConsumedAmount" + FROM finance_payment_rows + INNER JOIN billing_consumed_rows + ON billing_consumed_rows."assignmentId" = finance_payment_rows."assignmentId" + AND billing_consumed_rows."billingAccount" = finance_payment_rows."billingAccount" + AND billing_consumed_rows."rowNumber" = finance_payment_rows."rowNumber" + WHERE billing_consumed_rows."currentConsumedAmount" IS DISTINCT FROM + finance_payment_rows."expectedConsumedAmount" +), +updated AS ( + UPDATE "billing-accounts"."ConsumedAmount" consumed_amount + SET + amount = matched_rows."expectedConsumedAmount", + "updatedAt" = CURRENT_TIMESTAMP + FROM matched_rows + WHERE consumed_amount.id = matched_rows.id + RETURNING + consumed_amount.id, + consumed_amount."billingAccountId", + consumed_amount."externalId", + consumed_amount.amount +) +SELECT + COUNT(*) AS "billingConsumedRowsUpdated", + COUNT(DISTINCT "externalId") AS "assignmentsAffected", + COUNT(DISTINCT "billingAccountId") AS "billingAccountsAffected" +FROM updated; + -- Post-update sample for verification. SELECT w.winning_id, @@ -155,13 +313,18 @@ SELECT p.total_amount, p.challenge_markup, p.challenge_fee, - ba.markup AS "billingAccountMarkup" + ba.markup AS "billingAccountMarkup", + consumed_amount.amount AS "billingAccountConsumedAmount" FROM "finance"."winnings" w INNER JOIN "finance"."payment" p ON p.winnings_id = w.winning_id INNER JOIN "billing-accounts"."BillingAccount" ba ON p.billing_account ~ '^\d+$' AND ba.id::text = p.billing_account +LEFT JOIN "billing-accounts"."ConsumedAmount" consumed_amount + ON consumed_amount."externalType"::text = 'ENGAGEMENT' + AND consumed_amount."externalId" = w.external_id + AND consumed_amount."billingAccountId"::text = p.billing_account WHERE w.category::text = 'ENGAGEMENT_PAYMENT' ORDER BY p.updated_at DESC NULLS LAST, p.created_at DESC NULLS LAST LIMIT 25; From e7419a870c873294061a3565d1a8b723f2cf1a2b Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 29 Apr 2026 08:59:58 +1000 Subject: [PATCH 18/19] PM-4958: Release engagement BA consumption on cancellation What was broken Cancelling an engagement payment from wallet-admin updated the finance payment status, but the engagement consumed row in the billing-account ledger stayed in place. The BA details modal still showed the cancelled engagement amount as consumed. Root cause (if identifiable) The previous PM-4958 fix only recalculated challenge payment budget rows. Engagement payments were explicitly excluded from that path, and their BA consumed rows are maintained separately from challenge budget rows. What was changed Added wallet-admin cancellation handling for engagement payments. After the finance status transaction succeeds, the admin service now recalculates the active non-cancelled engagement ledger amounts and asks the billing-account service to sync the matching consumed rows, deleting stale rows when cancelled payments should no longer reserve budget. The sync supports both the legacy challengeId BA ledger shape and the newer typed externalId/externalType shape. Any added/updated tests Added AdminService coverage for engagement payment cancellation triggering BA sync. Added BillingAccountsService coverage for deleting stale engagement consumed rows, syncing active typed rows, and syncing legacy aggregate rows. --- src/api/admin/admin.service.spec.ts | 58 +++++ src/api/admin/admin.service.ts | 150 ++++++++++++ .../topcoder/billing-accounts.service.spec.ts | 98 ++++++++ .../topcoder/billing-accounts.service.ts | 215 ++++++++++++++++++ 4 files changed, 521 insertions(+) diff --git a/src/api/admin/admin.service.spec.ts b/src/api/admin/admin.service.spec.ts index b69ae53..f0a3a7b 100644 --- a/src/api/admin/admin.service.spec.ts +++ b/src/api/admin/admin.service.spec.ts @@ -74,6 +74,7 @@ describe('AdminService', () => { getBillingAccountById: jest.Mock; getBillingAccountsForUser: jest.Mock; lockConsumeAmount: jest.Mock; + syncEngagementConsumeAmounts: jest.Mock; }; let accessControlService: { verifyAccess: jest.Mock; @@ -120,6 +121,7 @@ describe('AdminService', () => { .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), @@ -457,6 +459,62 @@ describe('AdminService', () => { }); }); + 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', diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 4f9db84..060f36b 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -36,12 +36,18 @@ import { import { WinningPaymentDetailsDto } from './dto/payment-details.dto'; const PAYMENT_DECIMAL_PLACES = 2; +const BUDGET_LEDGER_DECIMAL_PLACES = 4; interface ChallengeBudgetSyncTarget { billingAccountId: number; challengeId: string; } +interface EngagementBudgetSyncTarget { + assignmentId: string; + billingAccountId: number; +} + /** * The admin winning service. */ @@ -394,6 +400,55 @@ export class AdminService { return [...targets.values()]; } + /** + * Finds engagement billing-account rows that need to be reconciled after a + * wallet-admin payment status change. + * + * @param payments payment rows selected by the update request. + * @returns unique engagement assignment and billing-account pairs touched by + * USD engagement payments. + * @throws This helper does not throw. + */ + private getEngagementBudgetSyncTargets( + payments: Awaited>, + ): EngagementBudgetSyncTarget[] { + const targets = new Map(); + + payments.forEach((payment) => { + const assignmentId = + typeof payment.winnings.external_id === 'string' + ? payment.winnings.external_id.trim() + : ''; + + if ( + !assignmentId || + payment.winnings.type !== winnings_type.PAYMENT || + payment.winnings.category !== winnings_category.ENGAGEMENT_PAYMENT || + (payment.currency ?? '') !== 'USD' + ) { + return; + } + + const billingAccountId = this.normalizeBillingAccountId( + payment.billing_account, + ); + + if (!billingAccountId) { + this.logger.warn( + `Skipping engagement budget sync for payment ${payment.payment_id}; invalid billing account ${String(payment.billing_account)}`, + ); + return; + } + + targets.set(`${assignmentId}:${billingAccountId}`, { + assignmentId, + billingAccountId, + }); + }); + + return [...targets.values()]; + } + /** * Resolves the billing account to synchronize for a challenge. * @@ -465,6 +520,25 @@ export class AdminService { ); } + /** + * Quantizes a billing-account ledger amount to the same four-decimal scale + * used by billing-accounts-api-v6. + * + * @param amount decimal amount to normalize. + * @returns JavaScript number rounded to four decimal places. + * @throws This helper does not throw. + */ + private toBillingLedgerAmount(amount: Prisma.Decimal): number { + return Number( + amount + .toDecimalPlaces( + BUDGET_LEDGER_DECIMAL_PLACES, + Prisma.Decimal.ROUND_HALF_UP, + ) + .toFixed(BUDGET_LEDGER_DECIMAL_PLACES), + ); + } + /** * Sums non-cancelled USD payment rows for a challenge and billing account. * @@ -547,6 +621,74 @@ export class AdminService { ); } + /** + * Reads active engagement payment ledger amounts for one assignment. + * + * @param assignmentId engagement assignment external id stored on winnings. + * @param billingAccountId billing account stored on payment rows. + * @returns ledger-scale consumed amounts for non-cancelled finance payments, + * in the same order used when engagement consumed rows were created. + * @throws Prisma errors when the payment query fails. + */ + private async getActiveEngagementConsumeAmounts( + assignmentId: string, + billingAccountId: number, + ): Promise { + const payments = await this.prisma.payment.findMany({ + select: { + challenge_fee: true, + total_amount: true, + }, + where: { + billing_account: String(billingAccountId), + currency: PrizeType.USD, + payment_status: { not: payment_status.CANCELLED }, + winnings: { + category: winnings_category.ENGAGEMENT_PAYMENT, + external_id: assignmentId, + type: winnings_type.PAYMENT, + }, + }, + orderBy: [{ created_at: 'asc' }, { payment_id: 'asc' }], + }); + + return payments.map((paymentRow) => + this.toBillingLedgerAmount( + new Prisma.Decimal(paymentRow.total_amount ?? 0).plus( + new Prisma.Decimal(paymentRow.challenge_fee ?? 0), + ), + ), + ); + } + + /** + * Reconciles engagement billing-account consumed rows after a wallet-admin + * cancellation changes the active payment set. + * + * @param targets unique engagement assignment and billing-account pairs to + * synchronize. + * @returns promise resolved after every target has been reconciled in BA. + * @throws Error when BA synchronization fails. + */ + private async syncEngagementBudgetTargets( + targets: EngagementBudgetSyncTarget[], + ): Promise { + await Promise.all( + targets.map(async (target) => { + const amounts = await this.getActiveEngagementConsumeAmounts( + target.assignmentId, + target.billingAccountId, + ); + + await this.baService.syncEngagementConsumeAmounts({ + amounts, + billingAccountId: target.billingAccountId, + externalId: target.assignmentId, + }); + }), + ); + } + /** * Verify that a BA admin user has access to the billing account(s) * associated with the given winningsId. Throws BadRequestException when @@ -642,6 +784,10 @@ export class AdminService { body.paymentStatus === PaymentStatus.CANCELLED ? this.getChallengeBudgetSyncTargets(payments) : []; + const engagementBudgetSyncTargets = + body.paymentStatus === PaymentStatus.CANCELLED + ? this.getEngagementBudgetSyncTargets(payments) + : []; // iterate payments and build transaction list payments.forEach((payment) => { @@ -928,6 +1074,10 @@ export class AdminService { await this.syncChallengeBudgetTargets(challengeBudgetSyncTargets); } + if (engagementBudgetSyncTargets.length > 0) { + await this.syncEngagementBudgetTargets(engagementBudgetSyncTargets); + } + if (needsReconciliation) { const winning = await this.prisma.winnings.findFirst({ select: { diff --git a/src/shared/topcoder/billing-accounts.service.spec.ts b/src/shared/topcoder/billing-accounts.service.spec.ts index 2ec7eb7..8f2da0b 100644 --- a/src/shared/topcoder/billing-accounts.service.spec.ts +++ b/src/shared/topcoder/billing-accounts.service.spec.ts @@ -17,6 +17,18 @@ jest.mock('src/shared/global', () => ({ }, })); +const mockBaTx = { + $executeRawUnsafe: jest.fn(), + $queryRawUnsafe: jest.fn(), +}; +const mockBaClient = { + $transaction: jest.fn(), +}; + +jest.mock('src/shared/global/ba-prisma.client', () => ({ + getBaClient: jest.fn(() => mockBaClient), +})); + import { ChallengeStatuses } from 'src/dto/challenge.dto'; import { BillingAccountsService } from './billing-accounts.service'; @@ -24,6 +36,15 @@ describe('BillingAccountsService', () => { let service: BillingAccountsService; beforeEach(() => { + jest.clearAllMocks(); + mockBaTx.$executeRawUnsafe.mockResolvedValue(undefined); + mockBaTx.$queryRawUnsafe + .mockResolvedValueOnce([{ exists: true }]) + .mockResolvedValueOnce([]); + mockBaClient.$transaction.mockImplementation( + (callback: (tx: typeof mockBaTx) => unknown) => callback(mockBaTx), + ); + service = new BillingAccountsService({} as any); }); @@ -95,4 +116,81 @@ describe('BillingAccountsService', () => { }); expect(consumeAmountSpy).not.toHaveBeenCalled(); }); + + it('deletes stale engagement consumed rows when no active amounts remain', async () => { + mockBaTx.$queryRawUnsafe + .mockReset() + .mockResolvedValueOnce([{ exists: true }]) + .mockResolvedValueOnce([{ id: 'consumed-row-1' }]); + + await service.syncEngagementConsumeAmounts({ + amounts: [], + billingAccountId: 80001012, + externalId: 'assignment-1', + }); + + expect(mockBaTx.$queryRawUnsafe).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('"externalId" = $2'), + 80001012, + 'assignment-1', + ); + expect(mockBaTx.$executeRawUnsafe).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM "ConsumedAmount"'), + 'consumed-row-1', + ); + }); + + it('syncs engagement consumed rows to the active ledger amounts', async () => { + mockBaTx.$queryRawUnsafe + .mockReset() + .mockResolvedValueOnce([{ exists: true }]) + .mockResolvedValueOnce([{ id: 'consumed-row-1' }, { id: 'stale-row-1' }]); + + await service.syncEngagementConsumeAmounts({ + amounts: [24.2, 12], + billingAccountId: 80001012, + externalId: 'assignment-1', + }); + + expect(mockBaTx.$executeRawUnsafe).toHaveBeenCalledWith( + expect.stringContaining('UPDATE "ConsumedAmount"'), + 24.2, + 'consumed-row-1', + ); + expect(mockBaTx.$executeRawUnsafe).toHaveBeenCalledWith( + expect.stringContaining('UPDATE "ConsumedAmount"'), + 12, + 'stale-row-1', + ); + expect(mockBaTx.$executeRawUnsafe).not.toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM "ConsumedAmount"'), + expect.any(String), + ); + }); + + it('syncs legacy consumed rows with the aggregate active amount', async () => { + mockBaTx.$queryRawUnsafe + .mockReset() + .mockResolvedValueOnce([{ exists: false }]) + .mockResolvedValueOnce([{ id: 'legacy-row-1' }]); + + await service.syncEngagementConsumeAmounts({ + amounts: [24.2, 12], + billingAccountId: 80001012, + externalId: 'assignment-1', + }); + + expect(mockBaTx.$queryRawUnsafe).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('"challengeId" = $2'), + 80001012, + 'assignment-1', + ); + expect(mockBaTx.$executeRawUnsafe).toHaveBeenCalledWith( + expect.stringContaining('UPDATE "ConsumedAmount"'), + 36.2, + 'legacy-row-1', + ); + }); }); diff --git a/src/shared/topcoder/billing-accounts.service.ts b/src/shared/topcoder/billing-accounts.service.ts index 9e48f50..cc57d51 100644 --- a/src/shared/topcoder/billing-accounts.service.ts +++ b/src/shared/topcoder/billing-accounts.service.ts @@ -3,6 +3,7 @@ import { isNumber, includes } from 'lodash'; import { ENV_CONFIG } from 'src/config'; import { ChallengeStatuses } from 'src/dto/challenge.dto'; import { Logger } from 'src/shared/global'; +import { getBaClient } from 'src/shared/global/ba-prisma.client'; import { TopcoderM2MHttpError, TopcoderM2MService, @@ -48,6 +49,21 @@ interface ConsumeAmountsDTO { consumes: ConsumeAmountsItemDTO[]; } +interface SyncEngagementConsumeAmountsDTO { + amounts: number[]; + billingAccountId: number; + externalId: string; +} + +interface BillingAccountLedgerRow { + id: string; +} + +interface BillingAccountLedgerTransaction { + $executeRawUnsafe(query: string, ...values: unknown[]): Promise; + $queryRawUnsafe(query: string, ...values: unknown[]): Promise; +} + interface BillingAccountDetailsResponse { id: number | string; markup: number | string; @@ -348,6 +364,205 @@ export class BillingAccountsService { } } + /** + * Detects whether the billing-account ledger uses typed external references. + * + * @param tx active billing-account Prisma transaction. + * @returns true when consumed rows expose `externalId` and `externalType`, + * false for the legacy `challengeId` column shape. + * @throws Prisma errors when the metadata query fails. + */ + private async hasTypedConsumedAmountReferences( + tx: BillingAccountLedgerTransaction, + ): Promise { + const rows = await tx.$queryRawUnsafe<{ exists: boolean }[]>( + `SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'ConsumedAmount' + AND table_schema = ANY (current_schemas(false)) + AND column_name = 'externalId' + ) AS "exists"`, + ); + + return Boolean(rows[0]?.exists); + } + + /** + * Reads consumed ledger rows for one engagement assignment. + * + * @param tx active billing-account Prisma transaction. + * @param billingAccountId target billing account id. + * @param externalId engagement assignment id. + * @param hasTypedReferences whether the ledger uses typed external reference + * columns. + * @returns matching consumed row ids ordered by ledger creation order. + * @throws Prisma errors when the row query fails. + */ + private async getEngagementConsumedRows( + tx: BillingAccountLedgerTransaction, + billingAccountId: number, + externalId: string, + hasTypedReferences: boolean, + ): Promise { + if (hasTypedReferences) { + return tx.$queryRawUnsafe( + `SELECT id + FROM "ConsumedAmount" + WHERE "billingAccountId" = $1 + AND "externalId" = $2 + AND "externalType"::text = 'ENGAGEMENT' + ORDER BY "createdAt" ASC, id ASC`, + billingAccountId, + externalId, + ); + } + + return tx.$queryRawUnsafe( + `SELECT id + FROM "ConsumedAmount" + WHERE "billingAccountId" = $1 + AND "challengeId" = $2 + ORDER BY "createdAt" ASC, id ASC`, + billingAccountId, + externalId, + ); + } + + /** + * Rewrites one consumed ledger row amount. + * + * @param tx active billing-account Prisma transaction. + * @param rowId consumed row id. + * @param amount ledger-scale amount to persist. + * @returns promise resolved after the row is updated. + * @throws Prisma errors when the update fails. + */ + private async updateEngagementConsumedRow( + tx: BillingAccountLedgerTransaction, + rowId: string, + amount: number, + ): Promise { + await tx.$executeRawUnsafe( + `UPDATE "ConsumedAmount" + SET amount = $1, + "updatedAt" = CURRENT_TIMESTAMP + WHERE id = $2`, + amount, + rowId, + ); + } + + /** + * Deletes consumed ledger rows that no longer have active finance payments. + * + * @param tx active billing-account Prisma transaction. + * @param rows consumed rows to remove. + * @returns promise resolved after every row is deleted. + * @throws Prisma errors when a delete fails. + */ + private async deleteEngagementConsumedRows( + tx: BillingAccountLedgerTransaction, + rows: BillingAccountLedgerRow[], + ): Promise { + for (const row of rows) { + await tx.$executeRawUnsafe( + `DELETE FROM "ConsumedAmount" + WHERE id = $1`, + row.id, + ); + } + } + + /** + * Reconciles consumed BA ledger rows for one engagement assignment. + * + * Engagement consumes are written as one billing-account consumed row per + * finance payment. Wallet-admin cancellation must therefore rewrite the rows + * for the assignment to match the still-active finance payments and delete + * stale rows for payments that are now cancelled. + * + * @param dto engagement assignment external id, billing account id, and + * active ledger amounts to keep. + * @returns promise resolved after the billing-account ledger rows are synced. + * @throws BadRequestException when the sync target or amounts are invalid. + * @throws Prisma errors when the billing-account database update fails. + */ + async syncEngagementConsumeAmounts( + dto: SyncEngagementConsumeAmountsDTO, + ): Promise { + const externalId = + typeof dto.externalId === 'string' ? dto.externalId.trim() : ''; + + if ( + !Number.isSafeInteger(dto.billingAccountId) || + dto.billingAccountId <= 0 + ) { + throw new BadRequestException( + 'billingAccountId must be a positive integer', + ); + } + + if (!externalId) { + throw new BadRequestException('externalId is required'); + } + + if (includes(TGBillingAccounts, dto.billingAccountId)) { + this.logger.info( + 'Ignore BA validation for Topgear account:', + dto.billingAccountId, + ); + return; + } + + const amounts = dto.amounts.filter((amount) => { + if (!Number.isFinite(amount) || amount < 0) { + throw new BadRequestException( + 'engagement consume amounts must be non-negative finite numbers', + ); + } + + return amount > 0; + }); + + const baClient = getBaClient(); + + await baClient.$transaction(async (tx) => { + const hasTypedReferences = + await this.hasTypedConsumedAmountReferences(tx); + const existingRows = await this.getEngagementConsumedRows( + tx, + dto.billingAccountId, + externalId, + hasTypedReferences, + ); + const syncAmounts = hasTypedReferences + ? amounts + : [ + Number(amounts.reduce((sum, amount) => sum + amount, 0).toFixed(4)), + ].filter((amount) => amount > 0); + + for (const [index, amount] of syncAmounts.entries()) { + const existingRow = existingRows[index]; + + if (existingRow) { + await this.updateEngagementConsumedRow(tx, existingRow.id, amount); + continue; + } + + this.logger.warn( + `Missing engagement consumed row for assignment ${externalId} on billing account ${dto.billingAccountId}`, + ); + } + + const staleRows = existingRows.slice(syncAmounts.length); + + if (staleRows.length > 0) { + await this.deleteEngagementConsumedRows(tx, staleRows); + } + }); + } + async lockConsumeAmount( baValidation: BAValidation, rollback: boolean = false, From 1c1e7ec8dd2de57cfa8bb6130de4e01aa5b98538 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 29 Apr 2026 18:55:00 +0300 Subject: [PATCH 19/19] Keep payment status --- src/api/challenges/challenges.service.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/api/challenges/challenges.service.ts b/src/api/challenges/challenges.service.ts index feb419e..b420ec0 100644 --- a/src/api/challenges/challenges.service.ts +++ b/src/api/challenges/challenges.service.ts @@ -513,12 +513,6 @@ export class ChallengesService { ); return payments.map((payment) => { - const paymentStatus = - payment.status ?? - (challenge.task?.isTask && payment.currency === PrizeType.USD - ? PaymentStatus.ON_HOLD_ADMIN - : undefined); - return { winnerId: payment.userId.toString(), type: @@ -530,7 +524,7 @@ export class ChallengesService { title: challenge.name, description: payment.description || challenge.name, externalId: challenge.id, - ...(paymentStatus ? { status: paymentStatus } : {}), + ...(payment.status ? { status: payment.status } : {}), details: [ { totalAmount: payment.amount,