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/admin/admin.service.spec.ts b/src/api/admin/admin.service.spec.ts index d74b63f..f0a3a7b 100644 --- a/src/api/admin/admin.service.spec.ts +++ b/src/api/admin/admin.service.spec.ts @@ -40,17 +40,42 @@ jest.mock('src/shared/topcoder/members.service', () => ({ TopcoderMembersService: class {}, })); +jest.mock('src/shared/topcoder/challenges.service', () => ({ + TopcoderChallengesService: class {}, +})); + import { AdminService } from './admin.service'; +import { PaymentStatus } from 'src/dto/payment.dto'; describe('AdminService', () => { let service: AdminService; let prisma: { + $transaction: jest.Mock; + audit: { + create: jest.Mock; + findMany: jest.Mock; + }; + payment: { + findMany: jest.Mock; + update: jest.Mock; + }; + payment_releases: { + findFirst: jest.Mock; + updateMany: jest.Mock; + }; winnings: { findFirst: jest.Mock; }; }; - let paymentsService: object; - let baService: object; + let paymentsService: { + reconcileUserPayments: jest.Mock; + }; + let baService: { + getBillingAccountById: jest.Mock; + getBillingAccountsForUser: jest.Mock; + lockConsumeAmount: jest.Mock; + syncEngagementConsumeAmounts: jest.Mock; + }; let accessControlService: { verifyAccess: jest.Mock; }; @@ -61,15 +86,43 @@ describe('AdminService', () => { let tcMembersService: { getHandlesByUserIds: jest.Mock; }; + let topcoderChallengesService: { + getChallengeById: jest.Mock; + getProjectById: jest.Mock; + }; beforeEach(() => { prisma = { + $transaction: jest.fn((callback: (tx: unknown) => unknown) => + Promise.resolve(callback(prisma)), + ), + audit: { + create: jest.fn().mockResolvedValue({}), + findMany: jest.fn().mockResolvedValue([]), + }, + payment: { + findMany: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue({}), + }, + payment_releases: { + findFirst: jest.fn().mockResolvedValue(null), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, winnings: { findFirst: jest.fn(), }, }; - paymentsService = {}; - baService = {}; + paymentsService = { + reconcileUserPayments: jest.fn().mockResolvedValue(undefined), + }; + baService = { + getBillingAccountById: jest + .fn() + .mockResolvedValue({ id: 80001012, markup: 0.1 }), + getBillingAccountsForUser: jest.fn().mockResolvedValue(['80001012']), + lockConsumeAmount: jest.fn().mockResolvedValue(undefined), + syncEngagementConsumeAmounts: jest.fn().mockResolvedValue(undefined), + }; accessControlService = { verifyAccess: jest.fn().mockResolvedValue(undefined), }; @@ -82,6 +135,10 @@ describe('AdminService', () => { '654321': 'payment-manager', }), }; + topcoderChallengesService = { + getChallengeById: jest.fn().mockResolvedValue(undefined), + getProjectById: jest.fn().mockResolvedValue(undefined), + }; service = new AdminService( prisma as any, @@ -90,6 +147,7 @@ describe('AdminService', () => { accessControlService as any, topcoderEngagementsService as any, tcMembersService as any, + topcoderChallengesService as any, ); }); @@ -323,4 +381,183 @@ describe('AdminService', () => { service.getWinningPaymentDetails('missing-winning', '123456', []), ).rejects.toBeInstanceOf(NotFoundException); }); + + it('recalculates the challenge billing-account line item when a challenge payment is cancelled', async () => { + prisma.payment.findMany + .mockResolvedValueOnce([ + { + billing_account: '80001012', + currency: 'USD', + installment_number: 1, + payment_id: 'payment-1', + payment_status: PaymentStatus.OWED, + release_date: new Date('2026-04-27T00:00:00.000Z'), + version: 1, + winnings: { + category: 'CONTEST_PAYMENT', + external_id: 'challenge-1', + type: 'PAYMENT', + }, + }, + ]) + .mockResolvedValueOnce([ + { total_amount: '12.00' }, + { total_amount: '27.50' }, + ]); + topcoderChallengesService.getChallengeById.mockResolvedValue({ + billing: { + billingAccountId: '80001012', + markup: 0.1, + }, + id: 'challenge-1', + status: 'COMPLETED', + }); + + const result = await service.updateWinnings( + { + paymentId: 'payment-1', + paymentStatus: PaymentStatus.CANCELLED, + winningsId: 'winning-1', + } as any, + 'admin-1', + ['Payment Admin'], + ); + + expect(result.data).toBe('Successfully updated winnings'); + expect(prisma.payment.update).toHaveBeenCalledWith({ + where: { + payment_id: 'payment-1', + winnings_id: 'winning-1', + version: 1, + }, + data: { + date_paid: undefined, + payment_status: PaymentStatus.CANCELLED, + updated_at: expect.any(Date), + updated_by: 'admin-1', + version: 2, + }, + }); + expect(prisma.payment.findMany).toHaveBeenNthCalledWith(2, { + select: { total_amount: true }, + where: { + billing_account: '80001012', + currency: 'USD', + payment_status: { not: PaymentStatus.CANCELLED }, + winnings: { + external_id: 'challenge-1', + type: 'PAYMENT', + }, + }, + }); + expect(baService.lockConsumeAmount).toHaveBeenCalledWith({ + billingAccountId: 80001012, + challengeId: 'challenge-1', + markup: 0.1, + status: 'COMPLETED', + totalPrizesInCents: 3950, + }); + }); + + it('releases engagement billing-account rows when an engagement payment is cancelled', async () => { + prisma.payment.findMany + .mockResolvedValueOnce([ + { + billing_account: '80001012', + currency: 'USD', + installment_number: 1, + payment_id: 'payment-1', + payment_status: PaymentStatus.OWED, + release_date: new Date('2026-04-28T00:00:00.000Z'), + version: 1, + winnings: { + category: 'ENGAGEMENT_PAYMENT', + external_id: 'assignment-1', + type: 'PAYMENT', + }, + }, + ]) + .mockResolvedValueOnce([]); + + const result = await service.updateWinnings( + { + paymentId: 'payment-1', + paymentStatus: PaymentStatus.CANCELLED, + winningsId: 'winning-1', + } as any, + 'admin-1', + ['Payment Admin'], + ); + + expect(result.data).toBe('Successfully updated winnings'); + expect(prisma.payment.findMany).toHaveBeenNthCalledWith(2, { + select: { + challenge_fee: true, + total_amount: true, + }, + where: { + billing_account: '80001012', + currency: 'USD', + payment_status: { not: PaymentStatus.CANCELLED }, + winnings: { + category: 'ENGAGEMENT_PAYMENT', + external_id: 'assignment-1', + type: 'PAYMENT', + }, + }, + orderBy: [{ created_at: 'asc' }, { payment_id: 'asc' }], + }); + expect(baService.syncEngagementConsumeAmounts).toHaveBeenCalledWith({ + amounts: [], + billingAccountId: 80001012, + externalId: 'assignment-1', + }); + expect(baService.lockConsumeAmount).not.toHaveBeenCalled(); + }); + + it('returns task details for task payment with projectId and approver', async () => { + prisma.winnings.findFirst.mockResolvedValue({ + winning_id: 'winning-task', + category: 'TASK_PAYMENT', + created_by: '654321', + winner_id: '123456', + external_id: 'challenge-uuid-1', + attributes: {}, + }); + topcoderChallengesService.getChallengeById.mockResolvedValue({ + id: 'challenge-uuid-1', + name: 'Build a widget', + projectId: 42, + createdBy: 'challenge-creator', + }); + topcoderChallengesService.getProjectById.mockResolvedValue({ + id: 42, + name: 'My Project', + }); + prisma.audit.findMany.mockResolvedValue([ + { + id: 'audit-1', + winnings_id: 'winning-task', + user_id: '654321', + action: 'status updated from ON_HOLD_ADMIN to OWED', + note: null, + created_at: new Date(), + }, + ]); + + const result = await service.getWinningPaymentDetails( + 'winning-task', + '123456', + ['Payment Admin'], + ); + + expect(result.data?.taskDetails?.projectId).toBe('42'); + expect(result.data?.taskDetails?.projectName).toBe('My Project'); + expect(result.data?.taskDetails?.paymentCreatorHandle).toBe( + 'challenge-creator', + ); + expect(result.data?.taskDetails?.paymentApproverHandle).toBe( + 'payment-manager', + ); + }); }); diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 46ed714..060f36b 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,8 +29,25 @@ import { TopcoderEngagementsService, } from 'src/shared/topcoder/engagements.service'; import { TopcoderMembersService } from 'src/shared/topcoder/members.service'; +import { + TopcoderChallengeInfo, + TopcoderChallengesService, +} from 'src/shared/topcoder/challenges.service'; 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. */ @@ -43,6 +66,7 @@ export class AdminService { private readonly accessControlService: AccessControlService, private readonly topcoderEngagementsService: TopcoderEngagementsService, private readonly tcMembersService: TopcoderMembersService, + private readonly topcoderChallengesService: TopcoderChallengesService, ) {} async verifyUserAccessToWinning( @@ -289,6 +313,382 @@ 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()]; + } + + /** + * 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. + * + * @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), + ); + } + + /** + * 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. + * + * @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, + }); + }), + ); + } + + /** + * 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 @@ -380,6 +780,14 @@ export class AdminService { tx: Prisma.TransactionClient, ) => Promise)[] = []; const now = new Date().getTime(); + const challengeBudgetSyncTargets = + 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) => { @@ -662,6 +1070,14 @@ export class AdminService { `Successfully executed transactions for winningsId=${winningsId}`, ); + if (challengeBudgetSyncTargets.length > 0) { + await this.syncChallengeBudgetTargets(challengeBudgetSyncTargets); + } + + if (engagementBudgetSyncTargets.length > 0) { + await this.syncEngagementBudgetTargets(engagementBudgetSyncTargets); + } + if (needsReconciliation) { const winning = await this.prisma.winnings.findFirst({ select: { @@ -712,6 +1128,64 @@ 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?.createdBy) { + taskDetails.paymentCreatorHandle = await this.getPaymentCreatorHandle( + challenge.createdBy, + ); + } + 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: 'desc' }, + take: 200, + }); + 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) { + 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 +1221,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..95ebfdd 100644 --- a/src/api/admin/dto/payment-details.dto.ts +++ b/src/api/admin/dto/payment-details.dto.ts @@ -76,6 +76,32 @@ 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; + + @ApiPropertyOptional({ + description: 'The Topcoder handle of the challenge creator', + example: 'task_creator_handle', + }) + paymentCreatorHandle?: string; +} + export class WinningPaymentDetailsDto { @ApiPropertyOptional({ description: @@ -96,4 +122,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/api/challenges/challenges.service.spec.ts b/src/api/challenges/challenges.service.spec.ts index 8cea8ce..ac8a42c 100644 --- a/src/api/challenges/challenges.service.spec.ts +++ b/src/api/challenges/challenges.service.spec.ts @@ -24,6 +24,8 @@ 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'; +import { CHALLENGE_BUDGET_SYNC_SKIP_ATTRIBUTE } from '../winnings/winnings.service'; describe('ChallengesService', () => { it('skips creating payments for fun challenges', async () => { @@ -139,7 +141,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, @@ -165,4 +169,197 @@ 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, + }), + ]); + 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 () => { + 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, + }), + ]); + }); + + 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 ad0c5ef..b420ec0 100644 --- a/src/api/challenges/challenges.service.ts +++ b/src/api/challenges/challenges.service.ts @@ -27,7 +27,11 @@ 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 { WinningsService } from '../winnings/winnings.service'; +import { PaymentStatus } from 'src/dto/payment.dto'; +import { + CHALLENGE_BUDGET_SYNC_SKIP_ATTRIBUTE, + WinningsService, +} from '../winnings/winnings.service'; import { WinningRequestDto, WinningsCategory, @@ -43,6 +47,7 @@ interface PaymentPayload { type: WinningsCategory; currency: PrizeType; description?: string; + status?: PaymentStatus; } const placeToOrdinal = (place: number) => { @@ -59,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); @@ -189,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 []; } @@ -212,6 +238,9 @@ export class ChallengesService { userId: winner.userId.toString(), type: winType, currency, + ...(challenge.task?.isTask && currency === PrizeType.USD + ? { status: PaymentStatus.ON_HOLD_ADMIN } + : {}), description: challenge.type === 'Task' ? challenge.name @@ -277,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 []; } @@ -421,6 +446,9 @@ export class ChallengesService { ), type: winType, currency: placementPrizes?.[0]?.type ?? PrizeType.USD, + ...(challenge.task?.isTask && currency === PrizeType.USD + ? { status: PaymentStatus.ON_HOLD_ADMIN } + : {}), description: `${challenge.name} - ${phaseReviews[0].phaseName}`, }; }, @@ -484,35 +512,39 @@ 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, - 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) => { + 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, + ...(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, + }, + ], + 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) { @@ -604,12 +636,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/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 b9d2479..fbdaa4b 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, @@ -348,6 +354,7 @@ export class WinningsRepository { undefined, undefined, undefined, + undefined, [externalId], undefined, ); diff --git a/src/api/winnings/winnings.service.spec.ts b/src/api/winnings/winnings.service.spec.ts index 96cfbfe..e8ea82a 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, ); @@ -226,6 +244,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 () => { @@ -411,4 +439,194 @@ describe('WinningsService', () => { expect(Number(persistedPayment.challenge_markup)).toBe(0.2); 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, + payment_status: { not: 'CANCELLED' }, + 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: { + 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, + payment_status: { not: 'CANCELLED' }, + 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 9524cd3..4b7aa52 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,355 @@ 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]; + } + + /** + * 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. + * + * @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. 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( + 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; + } + + const billingAccountSyncIds = this.getChallengeBillingAccountSyncIds( + body, + challenge, + billingAccountIds, + ); + + return { + billingAccounts: await Promise.all( + billingAccountSyncIds.map(async (billingAccountId) => ({ + billingAccountId, + markup: await this.resolveChallengeBillingMarkup( + challenge, + billingAccountId, + ), + })), + ), + challengeId, + status: challenge.status, + }; + } + + /** + * 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 active 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, + payment_status: { not: payment_status.CANCELLED }, + 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. * @@ -398,7 +767,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 +777,7 @@ export class WinningsService { */ private calculateEngagementConsumeAmount( totalAmount: number, - markup: number, + challengeMarkup: number, detailIndex: number, ): number { if (!Number.isFinite(totalAmount) || totalAmount < 0) { @@ -416,14 +786,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 +909,7 @@ export class WinningsService { consumePlan.push({ amount: this.calculateEngagementConsumeAmount( Number(detail.totalAmount), - billingAccount.markup, + challengeMarkup, detailIndex, ), billingAccountId: trustedBillingAccountId, @@ -750,13 +1120,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, @@ -768,6 +1141,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( @@ -978,6 +1353,13 @@ export class WinningsService { await this.consumeEngagementBillingAccounts(engagementConsumePlan); } + if (challengeBillingAccountSyncPlan) { + await this.syncChallengeBillingAccountBudget( + tx, + challengeBillingAccountSyncPlan, + ); + } + if ( !isPointsAward && !payrollPayment && 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..0f9910b 100644 --- a/src/dto/winning.dto.ts +++ b/src/dto/winning.dto.ts @@ -170,7 +170,20 @@ 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/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; 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..44cc418 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,29 @@ 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< - Record - > = { - roleName: Role.EngagementPaymentApprover, - applyFilter: engagementApproverApplyFilter, + const approverProvider: RoleAccessProvider> = { + 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 +49,36 @@ 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< - Record - > = { - roleName: Role.EngagementPaymentApprover, - applyFilter: engagementApproverApplyFilter, + const approverProvider: RoleAccessProvider> = { + 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 +111,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/engagement-pa.provider.ts deleted file mode 100644 index c4a333e..0000000 --- a/src/shared/access-control/engagement-pa.provider.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { RoleAccessProvider } from './role-access.interface'; -import { PrismaService } from 'src/shared/global/prisma.service'; -import { Role } from 'src/core/auth/auth.constants'; -import { winnings_category } from '@prisma/client'; - -@Injectable() -export class EngagementPaymentApproverProvider implements RoleAccessProvider { - roleName = Role.EngagementPaymentApprover; - - 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; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async verifyAccessToResource(winningsId: string | string[], _userId: string) { - const winningsIds = ([] as string[]).concat(winningsId); - - const winnings = await this.prisma.winnings.findMany({ - where: { winning_id: { in: winningsIds } }, - select: { category: true }, - }); - - const unauthorized = winnings.filter( - (w) => w.category !== winnings_category.ENGAGEMENT_PAYMENT, - ); - if (unauthorized.length > 0) { - throw new Error( - `${Role.EngagementPaymentApprover} user is trying to access winning with category='${unauthorized.map((w) => w.category).join(', ')}'`, - ); - } - } -} 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..223f5f7 --- /dev/null +++ b/src/shared/access-control/payment-approver.provider.spec.ts @@ -0,0 +1,149 @@ +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('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 }, + { 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 new file mode 100644 index 0000000..24f15ca --- /dev/null +++ b/src/shared/access-control/payment-approver.provider.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { RoleAccessProvider } from './role-access.interface'; +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 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 { + 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: requestedCategories ?? allowedCategories, + } as T; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async verifyAccessToResource(winningsId: string | string[], _userId: string) { + const winningsIds = ([] as string[]).concat(winningsId); + + const winnings = await this.prisma.winnings.findMany({ + where: { winning_id: { in: winningsIds } }, + select: { category: true }, + }); + + 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(', ')}'`, + ); + } + } +} 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..8f2da0b --- /dev/null +++ b/src/shared/topcoder/billing-accounts.service.spec.ts @@ -0,0 +1,196 @@ +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(); + }, +})); + +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'; + +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); + }); + + 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(); + }); + + 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(); + }); + + 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 dab2164..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, @@ -10,6 +11,25 @@ import { const { TOPCODER_API_V6_BASE_URL, TGBillingAccounts } = ENV_CONFIG; +const LOCKED_CHALLENGE_STATUSES = [ + ChallengeStatuses.Draft, + ChallengeStatuses.Active, + 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; @@ -29,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; @@ -49,6 +84,31 @@ 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; +} + +/** + * 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); @@ -304,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, @@ -329,10 +588,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; @@ -357,34 +613,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, + }); } } } diff --git a/src/shared/topcoder/challenges.service.ts b/src/shared/topcoder/challenges.service.ts index 063a2de..3ac8bed 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,60 @@ export interface AdminPaymentUpdateData { amount: number; releaseDate: string; } + +export interface TopcoderChallengeInfo { + billing?: { + billingAccountId?: number | string | null; + clientBillingRate?: number | string | null; + markup?: number | string | null; + }; + createdBy?: string; + id: string; + name: string; + projectId: number; + status?: string; +} + +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; + } + } }