From 7ca6fd07a4ea7a458ff6d9d3c2c47625fd001640 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 20 Feb 2026 12:19:11 +1100 Subject: [PATCH 1/7] Additional index and optimization for CSV export on wallet-admin --- .../migration.sql | 1 + prisma/schema.prisma | 1 + src/api/admin/admin.controller.ts | 1 + src/api/repository/winnings.repo.ts | 3 +++ 4 files changed, 6 insertions(+) create mode 100644 prisma/migrations/20260219170000_add_winnings_type_created_at_index/migration.sql diff --git a/prisma/migrations/20260219170000_add_winnings_type_created_at_index/migration.sql b/prisma/migrations/20260219170000_add_winnings_type_created_at_index/migration.sql new file mode 100644 index 0000000..91a1541 --- /dev/null +++ b/prisma/migrations/20260219170000_add_winnings_type_created_at_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX idx_winnings_type_created_at ON winnings USING btree (type, created_at DESC); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 988305f..c241a38 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -147,6 +147,7 @@ model winnings { @@index([category, created_at(sort: Desc)], map: "idx_winnings_category_created_at") @@index([created_at(sort: Desc)], map: "idx_winnings_created_at") + @@index([type, created_at(sort: Desc)], map: "idx_winnings_type_created_at") @@index([winner_id, created_at(sort: Desc)], map: "idx_winnings_winner_created_at") @@index([winner_id, winning_id], map: "idx_winnings_winner_id_only") } diff --git a/src/api/admin/admin.controller.ts b/src/api/admin/admin.controller.ts index 4fd9332..c4f298e 100644 --- a/src/api/admin/admin.controller.ts +++ b/src/api/admin/admin.controller.ts @@ -140,6 +140,7 @@ export class AdminController { { includeCount: false, includePayoutStatus: false, + latestPaymentOnly: true, }, ); diff --git a/src/api/repository/winnings.repo.ts b/src/api/repository/winnings.repo.ts index d661c04..8c29f08 100644 --- a/src/api/repository/winnings.repo.ts +++ b/src/api/repository/winnings.repo.ts @@ -24,6 +24,7 @@ const ONE_DAY = 24 * 60 * 60 * 1000; interface SearchWinningsOptions { includeCount?: boolean; includePayoutStatus?: boolean; + latestPaymentOnly?: boolean; } @Injectable() @@ -172,6 +173,7 @@ export class WinningsRepository { const result = new ResponseDto(); const includeCount = options.includeCount ?? true; const includePayoutStatus = options.includePayoutStatus ?? true; + const latestPaymentOnly = options.latestPaymentOnly ?? false; try { let winnerIds: string[] | undefined; @@ -224,6 +226,7 @@ export class WinningsRepository { created_at: 'desc', }, ], + take: latestPaymentOnly ? 1 : undefined, }, origin: true, }, From 3827cbe8a10acdbabd630314c473a72225eb10a0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 22 Feb 2026 16:41:50 +1100 Subject: [PATCH 2/7] PM-1839: skip payment generation for fun challenges What was broken\nFinance payment generation treated leaderboard-only marathon fun challenges like normal payable challenges and attempted to create winnings records.\n\nRoot cause\nNo payment-generation guard existed for funChallenge in challenge processing, and the challenge model did not expose the flag.\n\nWhat was changed\nAdded funChallenge to the challenge model and short-circuited generateChallengePayments when it is true. Added Jest module alias mapping and minimal compile-safe typing fixes needed to execute the new unit test in this repo.\n\nAny added/updated tests\nAdded ChallengesService unit test to verify payment generation is skipped and no challenge lock is created for fun challenges. --- package.json | 5 +- src/api/challenges/challenges.service.spec.ts | 61 +++++++++++++++++++ src/api/challenges/challenges.service.ts | 9 ++- src/api/challenges/models/challenge.ts | 1 + src/api/repository/winnings.repo.ts | 2 +- .../topcoder/billing-accounts.service.ts | 3 +- 6 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 src/api/challenges/challenges.service.spec.ts diff --git a/package.json b/package.json index ec46ad4..2b079fb 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,10 @@ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "moduleNameMapper": { + "^src/(.*)$": "/$1" + } }, "prisma": { "seed": "ts-node prisma/seed.ts" diff --git a/src/api/challenges/challenges.service.spec.ts b/src/api/challenges/challenges.service.spec.ts new file mode 100644 index 0000000..88429c3 --- /dev/null +++ b/src/api/challenges/challenges.service.spec.ts @@ -0,0 +1,61 @@ +import { ChallengeStatuses } from '../../dto/challenge.dto'; + +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 { + debug = jest.fn(); + + error = jest.fn(); + + info = jest.fn(); + + log = jest.fn(); + + warn = jest.fn(); + }, +})); + +import { ChallengesService } from './challenges.service'; + +describe('ChallengesService', () => { + it('skips creating payments for fun challenges', async () => { + const prisma = { + challenge_lock: { + create: jest.fn(), + deleteMany: jest.fn(), + }, + }; + const service = new ChallengesService( + prisma as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + const challenge = { + funChallenge: true, + id: '11111111-1111-1111-1111-111111111111', + name: 'MM 163', + status: ChallengeStatuses.Completed, + }; + const createPaymentsSpy = jest + .spyOn(service as any, 'createPayments') + .mockResolvedValue(undefined); + + jest.spyOn(service, 'getChallenge').mockResolvedValue(challenge as any); + + await service.generateChallengePayments( + '11111111-1111-1111-1111-111111111111', + 'test-user', + ); + + expect(createPaymentsSpy).not.toHaveBeenCalled(); + expect(prisma.challenge_lock.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/challenges/challenges.service.ts b/src/api/challenges/challenges.service.ts index ca43bce..987976b 100644 --- a/src/api/challenges/challenges.service.ts +++ b/src/api/challenges/challenges.service.ts @@ -41,7 +41,7 @@ interface PaymentPayload { amount: number; userId: string; type: WinningsCategory; - currency: string; + currency: PrizeType; description?: string; } @@ -566,6 +566,13 @@ export class ChallengesService { throw new Error("Challenge isn't in a payable status!"); } + if (challenge.funChallenge === true) { + this.logger.log( + `Skipping payment generation for fun challenge ${challenge.id} (${challenge.name}).`, + ); + return; + } + // need to read for update (LOCK the rows) this.logger.log(`Attempting to acquire lock for challenge ${challenge.id}`); try { diff --git a/src/api/challenges/models/challenge.ts b/src/api/challenges/models/challenge.ts index 39ffb62..8b869a0 100644 --- a/src/api/challenges/models/challenge.ts +++ b/src/api/challenges/models/challenge.ts @@ -9,6 +9,7 @@ export interface Challenge { timelineTemplateId: string; currentPhaseNames: string[]; wiproAllowed: boolean; + funChallenge?: boolean; tags: string[]; groups: string[]; submissionStartDate: string; diff --git a/src/api/repository/winnings.repo.ts b/src/api/repository/winnings.repo.ts index 8c29f08..637c9a9 100644 --- a/src/api/repository/winnings.repo.ts +++ b/src/api/repository/winnings.repo.ts @@ -116,7 +116,7 @@ export class WinningsRepository { private getOrderByWithWinnerId( sortBy: string | undefined, - sortOrder: 'asc' | 'desc', + sortOrder: 'asc' | 'desc' | undefined, externalIds?: boolean, ) { const orderBy: object = [ diff --git a/src/shared/topcoder/billing-accounts.service.ts b/src/shared/topcoder/billing-accounts.service.ts index c161b60..5805e63 100644 --- a/src/shared/topcoder/billing-accounts.service.ts +++ b/src/shared/topcoder/billing-accounts.service.ts @@ -1,7 +1,8 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; 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 { TopcoderM2MService } from './topcoder-m2m.service'; const { TOPCODER_API_V6_BASE_URL, TGBillingAccounts } = ENV_CONFIG; From 54a1e4e9d3223ba3c3c92a202c3ce0e4f29c6728 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 22 Feb 2026 18:51:35 +1100 Subject: [PATCH 3/7] PM-2557: Fix admin date filter to exclude future-dated winnings What was broken:\nDate filters for wallet admin payments (last 7/30 days) did not narrow results when records had future created_at dates.\n\nRoot cause:\nThe date filter in WinningsRepository only applied a lower bound (gte) and had no upper bound, so future-dated rows still matched last7days/last30days.\n\nWhat was changed:\nUpdated generateFilterDate to apply both gte and lte bounds for LAST7DAYS and LAST30DAYS, with lte set to end-of-today.\nAdded repository unit tests to verify date windows for LAST7DAYS, LAST30DAYS, and ALL.\n\nAny added/updated tests:\nAdded src/api/repository/winnings.repo.spec.ts covering date filter range behavior in searchWinnings. --- src/api/repository/winnings.repo.spec.ts | 97 ++++++++++++++++++++++++ src/api/repository/winnings.repo.ts | 3 + 2 files changed, 100 insertions(+) create mode 100644 src/api/repository/winnings.repo.spec.ts diff --git a/src/api/repository/winnings.repo.spec.ts b/src/api/repository/winnings.repo.spec.ts new file mode 100644 index 0000000..128cb0d --- /dev/null +++ b/src/api/repository/winnings.repo.spec.ts @@ -0,0 +1,97 @@ +import { DateFilterType } from 'src/dto/date-filter.type'; + +jest.mock('src/shared/global', () => ({ + Logger: class { + debug = jest.fn(); + + error = jest.fn(); + + info = jest.fn(); + + log = jest.fn(); + + warn = jest.fn(); + }, +})); + +import { WinningsRepository } from './winnings.repo'; + +describe('WinningsRepository', () => { + const ONE_DAY = 24 * 60 * 60 * 1000; + + let findManyMock: jest.Mock; + let winningsRepo: WinningsRepository; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2025-10-24T03:20:35.000Z')); + + findManyMock = jest.fn().mockResolvedValue([]); + + const prismaMock = { + winnings: { + findMany: findManyMock, + }, + }; + + winningsRepo = new WinningsRepository(prismaMock as any); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + async function getCreatedAtFilter(date: DateFilterType): Promise { + await winningsRepo.searchWinnings( + { + date, + } as any, + { + includeCount: false, + includePayoutStatus: false, + }, + ); + + const findManyArgs = findManyMock.mock.calls[0][0]; + return findManyArgs.where.created_at; + } + + it('applies an inclusive 7-day range ending today for LAST7DAYS', async () => { + const createdAtFilter = await getCreatedAtFilter(DateFilterType.LAST7DAYS); + + expect(createdAtFilter).toEqual({ + gte: expect.any(Date), + lte: expect.any(Date), + }); + + expect(createdAtFilter.lte.getHours()).toBe(23); + expect(createdAtFilter.lte.getMinutes()).toBe(59); + expect(createdAtFilter.lte.getSeconds()).toBe(59); + expect(createdAtFilter.lte.getMilliseconds()).toBe(999); + expect(createdAtFilter.lte.getTime() - createdAtFilter.gte.getTime()).toBe( + 7 * ONE_DAY - 1, + ); + }); + + it('applies an inclusive 30-day range ending today for LAST30DAYS', async () => { + const createdAtFilter = await getCreatedAtFilter(DateFilterType.LAST30DAYS); + + expect(createdAtFilter).toEqual({ + gte: expect.any(Date), + lte: expect.any(Date), + }); + + expect(createdAtFilter.lte.getHours()).toBe(23); + expect(createdAtFilter.lte.getMinutes()).toBe(59); + expect(createdAtFilter.lte.getSeconds()).toBe(59); + expect(createdAtFilter.lte.getMilliseconds()).toBe(999); + expect(createdAtFilter.lte.getTime() - createdAtFilter.gte.getTime()).toBe( + 30 * ONE_DAY - 1, + ); + }); + + it('does not apply created_at bounds for ALL', async () => { + const createdAtFilter = await getCreatedAtFilter(DateFilterType.ALL); + + expect(createdAtFilter).toBeUndefined(); + }); +}); diff --git a/src/api/repository/winnings.repo.ts b/src/api/repository/winnings.repo.ts index 637c9a9..0711dcd 100644 --- a/src/api/repository/winnings.repo.ts +++ b/src/api/repository/winnings.repo.ts @@ -36,6 +36,7 @@ export class WinningsRepository { private generateFilterDate(date?: DateFilterType) { let filterDate: object | undefined; const currentDay = new Date(new Date().setHours(0, 0, 0, 0)); + const currentDayEnd = new Date(currentDay.getTime() + ONE_DAY - 1); switch (date) { case DateFilterType.LAST7DAYS: @@ -43,6 +44,7 @@ export class WinningsRepository { const last7days = new Date(currentDay.getTime() - 6 * ONE_DAY); filterDate = { gte: last7days, + lte: currentDayEnd, }; break; case DateFilterType.LAST30DAYS: @@ -50,6 +52,7 @@ export class WinningsRepository { const last30days = new Date(currentDay.getTime() - 29 * ONE_DAY); filterDate = { gte: last30days, + lte: currentDayEnd, }; break; case DateFilterType.ALL: From 81bc0d334d5c9f81eec19394134beb1fef3aaf10 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 24 Feb 2026 18:21:40 +1100 Subject: [PATCH 4/7] PM-4026: allow challenge-wide roles to view challenge payments What was broken: Challenge payment lookups in the review flow were forced to winner-only results for non-privileged users, so challenge managers reviewing completed challenges could see "No payments found" even when payments existed. Root cause: Challenge-wide access expansion only checked copilot roles. Manager/full-write challenge resource roles were not treated as challenge-wide readers. What was changed: Updated challenge-payments access checks to allow full challenge visibility for users with challenge-wide roles (fullWriteAccess roles plus manager/copilot role names). Kept winner-based filtering for users without challenge-wide access. Hardened role claim parsing to only stringify primitive claim values. Extended the challenge resource-role model with optional fullWriteAccess for this access check. Any added/updated tests: Added unit tests for ChallengePaymentsService to verify manager access returns all challenge payments and non-challenge-wide users remain winner-filtered. --- .../challenge-payments.service.spec.ts | 101 ++++++++++++++++++ .../challenge-payments.service.ts | 38 ++++--- src/api/challenges/models/challenge.ts | 1 + 3 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 src/api/challenge-payments/challenge-payments.service.spec.ts diff --git a/src/api/challenge-payments/challenge-payments.service.spec.ts b/src/api/challenge-payments/challenge-payments.service.spec.ts new file mode 100644 index 0000000..31d749e --- /dev/null +++ b/src/api/challenge-payments/challenge-payments.service.spec.ts @@ -0,0 +1,101 @@ +jest.mock('src/shared/global', () => ({ + Logger: class { + debug = jest.fn(); + + error = jest.fn(); + + info = jest.fn(); + + log = jest.fn(); + + warn = jest.fn(); + }, +})); + +jest.mock('src/config', () => ({ + ENV_CONFIG: { + TOPCODER_API_V6_BASE_URL: 'https://api.topcoder-dev.com/v6', + }, +})); + +import { ChallengePaymentsService } from './challenge-payments.service'; + +describe('ChallengePaymentsService', () => { + let findManyMock: jest.Mock; + let m2mFetchMock: jest.Mock; + let service: ChallengePaymentsService; + + beforeEach(() => { + findManyMock = jest.fn().mockResolvedValue([]); + m2mFetchMock = jest.fn(); + + service = new ChallengePaymentsService( + { + winnings: { + findMany: findManyMock, + }, + } as any, + { + m2mFetch: m2mFetchMock, + } as any, + ); + }); + + it('returns all challenge payments for managers', async () => { + m2mFetchMock + .mockResolvedValueOnce([ + { + memberHandle: 'manager-user', + memberId: '123', + roleId: 'manager-role-id', + }, + ]) + .mockResolvedValueOnce([ + { + fullWriteAccess: false, + id: 'manager-role-id', + name: 'Manager', + }, + ]); + + await service.listChallengePayments({ + auth0User: { roles: ['Topcoder User'] }, + challengeId: 'challenge-id', + isMachineToken: false, + requestUserId: '123', + winnerOnly: false, + }); + + const where = findManyMock.mock.calls[0][0].where; + expect(where.winner_id).toBeUndefined(); + }); + + it('keeps winner filtering for users without challenge-wide access', async () => { + m2mFetchMock + .mockResolvedValueOnce([ + { + memberHandle: 'reviewer-user', + memberId: '456', + roleId: 'reviewer-role-id', + }, + ]) + .mockResolvedValueOnce([ + { + fullWriteAccess: false, + id: 'reviewer-role-id', + name: 'Reviewer', + }, + ]); + + await service.listChallengePayments({ + auth0User: { roles: ['Topcoder User'] }, + challengeId: 'challenge-id', + isMachineToken: false, + requestUserId: '456', + winnerOnly: false, + }); + + const where = findManyMock.mock.calls[0][0].where; + expect(where.winner_id).toBe('456'); + }); +}); diff --git a/src/api/challenge-payments/challenge-payments.service.ts b/src/api/challenge-payments/challenge-payments.service.ts index 27bc857..2577d11 100644 --- a/src/api/challenge-payments/challenge-payments.service.ts +++ b/src/api/challenge-payments/challenge-payments.service.ts @@ -101,16 +101,17 @@ export class ChallengePaymentsService { } try { - const isCopilot = await this.isCopilotForChallenge( - challengeId, - requestUserId, - ); - if (isCopilot) { + const hasChallengeWideAccess = + await this.hasChallengeWideAccessForChallenge( + challengeId, + requestUserId, + ); + if (hasChallengeWideAccess) { allowAllForChallenge = true; } } catch (error) { this.logger.warn( - `Failed to verify copilot status for user ${requestUserId} on challenge ${challengeId}`, + `Failed to verify challenge-wide payment access for user ${requestUserId} on challenge ${challengeId}`, error instanceof Error ? error.message : error, ); } @@ -150,7 +151,11 @@ export class ChallengePaymentsService { roles.push(String(role)); } }); - } else if (value) { + } else if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { roles.push(String(value)); } }); @@ -165,7 +170,7 @@ export class ChallengePaymentsService { return roles; } - private async isCopilotForChallenge( + private async hasChallengeWideAccessForChallenge( challengeId: string, userId: string, ): Promise { @@ -181,19 +186,26 @@ export class ChallengePaymentsService { return false; } - const copilotRoleIds = new Set( + const challengeWideRoleIds = new Set( resourceRoles - ?.filter((role) => - role?.name ? role.name.toLowerCase().includes('copilot') : false, + ?.filter( + (role) => + role?.fullWriteAccess === true || + (role?.name + ? role.name.toLowerCase().includes('copilot') || + role.name.toLowerCase().includes('manager') + : false), ) .map((role) => role.id), ); - if (copilotRoleIds.size === 0) { + if (challengeWideRoleIds.size === 0) { return false; } - return resources.some((resource) => copilotRoleIds.has(resource.roleId)); + return resources.some((resource) => + challengeWideRoleIds.has(resource.roleId), + ); } private async fetchWinnings( diff --git a/src/api/challenges/models/challenge.ts b/src/api/challenges/models/challenge.ts index 8b869a0..b91a351 100644 --- a/src/api/challenges/models/challenge.ts +++ b/src/api/challenges/models/challenge.ts @@ -148,6 +148,7 @@ export interface ChallengeResource { export interface ResourceRole { id: string; name: string; + fullWriteAccess?: boolean; } export interface ChallengeReview { From 75f86d69503d4c89f86baf9c391be46b9c42fa84 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 4 Mar 2026 11:19:26 +0200 Subject: [PATCH 5/7] PM-4165 - allow editing credited payments --- src/api/admin/admin.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 4c5de83..7dc80bc 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -368,7 +368,8 @@ export class AdminService { // Update payment amount if requested if ( body.paymentAmount !== undefined && - (payment.payment_status === PaymentStatus.OWED || + (payment.payment_status === PaymentStatus.CREDITED || + payment.payment_status === PaymentStatus.OWED || payment.payment_status === PaymentStatus.ON_HOLD || payment.payment_status === PaymentStatus.ON_HOLD_ADMIN) ) { From 8fbc09596610748f07114d3580c20cce7bba8761 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 4 Mar 2026 11:19:49 +0200 Subject: [PATCH 6/7] deploy --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8a8c5bf..c0d082a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -75,6 +75,7 @@ workflows: only: - dev - engagements + - PM-4165_allow-editing-credited-payments - 'build-prod': context: org-global filters: From 496298ad209dd3e46f6b242fc5e638638677a999 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 4 Mar 2026 11:36:27 +0200 Subject: [PATCH 7/7] PM-4165 - allow editing credited payments --- src/api/admin/admin.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 7dc80bc..bae85fc 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -647,6 +647,7 @@ export class AdminService { version: currentVersion, payment_status: { in: [ + PaymentStatus.CREDITED, PaymentStatus.OWED, PaymentStatus.ON_HOLD, PaymentStatus.ON_HOLD_ADMIN,