diff --git a/.circleci/config.yml b/.circleci/config.yml index 7c6838b..972801b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -51,7 +51,7 @@ jobs: environment: DEPLOY_ENV: 'DEV' LOGICAL_ENV: 'dev' - APPNAME: 'tc-finance-api' + APPNAME: 'finance-api-v6' DEPLOYMENT_ENVIRONMENT: 'dev' steps: *build_and_deploy_steps @@ -60,7 +60,7 @@ jobs: environment: DEPLOY_ENV: 'PROD' LOGICAL_ENV: 'prod' - APPNAME: 'tc-finance-api' + APPNAME: 'finance-api-v6' DEPLOYMENT_ENVIRONMENT: 'prod' steps: *build_and_deploy_steps @@ -74,10 +74,10 @@ workflows: branches: only: - dev - - npm-module-updates + - v6 - 'build-prod': context: org-global filters: branches: only: - - master \ No newline at end of file + - master diff --git a/.env.sample b/.env.sample index 370bca4..69d0c09 100644 --- a/.env.sample +++ b/.env.sample @@ -1,4 +1,5 @@ -TOPCODER_API_BASE_URL="https://api.topcoder-dev.com/v5" +TOPCODER_API_V5_BASE_URL="https://api.topcoder-dev.com/v5" +TOPCODER_API_V6_BASE_URL="https://api.topcoder-dev.com/v6" AUTH0_CERT="-----BEGIN RSA PUBLIC KEY----- MIIBCgKCAQEArAV0dmDkedFdlaQ6KQiqUv+UGshfMXx/4jJCLZ9802ynJqAvIt+Z V7EiPqjc2J1xVfJJEvQ9ZS5A2TFWAk16NUTU4LN+TkjEnqeg+LlUPWY3Y4RXa2OU @@ -15,4 +16,4 @@ DB_PASSWORD=randompassword DB_HOST=127.0.0.1 DB_PORT=5434 DB_NAME=walletdb -DATABASE_URL="postgresql://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable" \ No newline at end of file +DATABASE_URL="postgresql://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable" diff --git a/prisma/migrations/20250929082539_remove_rewards_table/migration.sql b/prisma/migrations/20250929082539_remove_rewards_table/migration.sql new file mode 100644 index 0000000..2162985 --- /dev/null +++ b/prisma/migrations/20250929082539_remove_rewards_table/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the `reward` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "reward" DROP CONSTRAINT "reward_winnings_id_fkey"; + +-- DropTable +DROP TABLE "reward"; diff --git a/prisma/migrations/20250929082731_drop_payoneer_paypal_payment_methods/migration.sql b/prisma/migrations/20250929082731_drop_payoneer_paypal_payment_methods/migration.sql new file mode 100644 index 0000000..9041bde --- /dev/null +++ b/prisma/migrations/20250929082731_drop_payoneer_paypal_payment_methods/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - You are about to drop the `payoneer_payment_method` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `paypal_payment_method` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "payoneer_payment_method" DROP CONSTRAINT "fk_payoneer_user_payment_method"; + +-- DropForeignKey +ALTER TABLE "paypal_payment_method" DROP CONSTRAINT "fk_paypal_user_payment_method"; + +-- DropTable +DROP TABLE "payoneer_payment_method"; + +-- DropTable +DROP TABLE "paypal_payment_method"; diff --git a/prisma/migrations/20251020100641_challenge-processing-lock/migration.sql b/prisma/migrations/20251020100641_challenge-processing-lock/migration.sql new file mode 100644 index 0000000..5a54934 --- /dev/null +++ b/prisma/migrations/20251020100641_challenge-processing-lock/migration.sql @@ -0,0 +1,6 @@ +CREATE TABLE "challenge_lock" ( + "id" SERIAL PRIMARY KEY, + "external_id" VARCHAR(255) NOT NULL UNIQUE, + "lock_time" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "error" TEXT +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d073e89..4ae854a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -103,45 +103,11 @@ model payment_releases { payment_method payment_method @relation(fields: [payment_method_id], references: [payment_method_id], onDelete: NoAction, onUpdate: NoAction) } -model payoneer_payment_method { - id Int @id @default(autoincrement()) - user_payment_method_id String? @db.Uuid - user_id String @unique @db.VarChar(80) - payee_id String @db.VarChar(50) - payoneer_id String? @db.VarChar(50) - user_payment_methods user_payment_methods? @relation(fields: [user_payment_method_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "fk_payoneer_user_payment_method") -} - -model paypal_payment_method { - id Int @id @default(autoincrement()) - user_payment_method_id String? @db.Uuid - user_id String @unique @db.VarChar(80) - email String? @db.VarChar(150) - payer_id String? @db.VarChar(50) - country_code String? @db.VarChar(2) - user_payment_methods user_payment_methods? @relation(fields: [user_payment_method_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "fk_paypal_user_payment_method") -} - -model reward { - reward_id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - winnings_id String @db.Uuid - points Int? - title String? @db.VarChar(255) - description String? - reference Json? - attributes Json? - created_at DateTime? @default(now()) @db.Timestamp(6) - updated_at DateTime? @default(now()) @db.Timestamp(6) - winnings winnings @relation(fields: [winnings_id], references: [winning_id], onDelete: NoAction, onUpdate: NoAction) -} - model user_payment_methods { id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid user_id String @db.VarChar(80) payment_method_id Int status payment_method_status? @default(OTP_PENDING) - payoneer_payment_method payoneer_payment_method[] - paypal_payment_method paypal_payment_method[] trolley_payment_method trolley_recipient[] payment_method payment_method @relation(fields: [payment_method_id], references: [payment_method_id], onDelete: NoAction, onUpdate: NoAction, map: "fk_user_payment_method") @@ -172,7 +138,6 @@ model winnings { updated_at DateTime? @default(now()) @db.Timestamp(6) audit audit[] payment payment[] - reward reward[] origin origin? @relation(fields: [origin_id], references: [origin_id], onDelete: NoAction, onUpdate: NoAction) @@index([category, created_at(sort: Desc)], map: "idx_winnings_category_created_at") @@ -223,6 +188,13 @@ model trolley_recipient_payment_method { trolley_recipient trolley_recipient @relation(fields: [trolley_recipient_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "fk_trolley_recipient_trolley_recipient_payment_method") } +model challenge_lock { + id Int @id @default(autoincrement()) + external_id String @unique @db.VarChar(255) + lock_time DateTime @default(now()) @db.Timestamp(6) + error String? @db.Text +} + enum verification_status { ACTIVE INACTIVE diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 72bd34b..1d47f7c 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -13,10 +13,7 @@ import { ResponseDto } from 'src/dto/api-response.dto'; import { PaymentStatus } from 'src/dto/payment.dto'; import { WinningAuditDto, AuditPayoutDto } from './dto/audit.dto'; import { WinningUpdateRequestDto } from './dto/winnings.dto'; -import { - AdminPaymentUpdateData, - TopcoderChallengesService, -} from 'src/shared/topcoder/challenges.service'; +import { TopcoderChallengesService } from 'src/shared/topcoder/challenges.service'; import { Logger } from 'src/shared/global'; function formatDate(date = new Date()) { @@ -317,30 +314,6 @@ export class AdminService { } }); - transactions.push(async () => { - const winning = await this.getWinningById(winningsId); - if (!winning) { - this.logger.error( - `Error updating legacy system for winning ${winningsId}. Winning not found!`, - ); - throw new Error( - `Error updating legacy system for winning ${winningsId}. Winning not found!`, - ); - } - - const payoutData: AdminPaymentUpdateData = { - userId: +winning.winner_id, - status: body.paymentStatus, - amount: body.paymentAmount, - releaseDate: formatDate(new Date(body.releaseDate)), - }; - - await this.tcChallengesService.updateLegacyPayments( - winning.external_id as string, - payoutData, - ); - }); - // Run all transaction tasks in a single prisma transaction await this.prisma.$transaction(async (tx) => { for (const transaction of transactions) { diff --git a/src/api/api.module.ts b/src/api/api.module.ts index 2da3f93..2933211 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -17,6 +17,8 @@ import { WinningsModule } from './winnings/winnings.module'; import { UserModule } from './user/user.module'; import { WalletModule } from './wallet/wallet.module'; import { WithdrawalModule } from './withdrawal/withdrawal.module'; +import { ChallengesModule } from './challenges/challenges.module'; +import { ChallengePaymentsModule } from './challenge-payments/challenge-payments.module'; @Module({ imports: [ @@ -26,9 +28,11 @@ import { WithdrawalModule } from './withdrawal/withdrawal.module'; WebhooksModule, AdminModule, WinningsModule, + ChallengePaymentsModule, UserModule, WalletModule, WithdrawalModule, + ChallengesModule, ], controllers: [HealthCheckController], providers: [ diff --git a/src/api/challenge-payments/challenge-payments.controller.ts b/src/api/challenge-payments/challenge-payments.controller.ts new file mode 100644 index 0000000..d88e6f9 --- /dev/null +++ b/src/api/challenge-payments/challenge-payments.controller.ts @@ -0,0 +1,43 @@ +import { + Controller, + Get, + Param, + Query, + Req, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AllowedM2mScope, Roles, User } from 'src/core/auth/decorators'; +import { M2mScope, Role } from 'src/core/auth/auth.constants'; +import { UserInfo } from 'src/dto/user.type'; +import { ChallengePaymentsService } from './challenge-payments.service'; + +@ApiTags('Payments') +@Controller('/challenge-payments') +@ApiBearerAuth() +export class ChallengePaymentsController { + constructor( + private readonly challengePaymentsService: ChallengePaymentsService, + ) {} + + @Get('/:challengeId') + @AllowedM2mScope(M2mScope.ReadPayments, M2mScope.CreatePayments) + @Roles(Role.PaymentAdmin, Role.PaymentEditor, Role.PaymentViewer, Role.User) + @ApiOperation({ + summary: + 'List payments (winnings) for a challenge with role-aware filtering', + }) + async getChallengePayments( + @Param('challengeId') challengeId: string, + @Query('winnerOnly') winnerOnly: string | undefined, + @User() user: UserInfo, + @Req() req: any, + ) { + return this.challengePaymentsService.listChallengePayments({ + challengeId, + requestUserId: user?.id, + isMachineToken: Boolean(req?.m2mTokenScope), + winnerOnly: (winnerOnly || '').toLowerCase() === 'true', + auth0User: req?.auth0User, + }); + } +} diff --git a/src/api/challenge-payments/challenge-payments.module.ts b/src/api/challenge-payments/challenge-payments.module.ts new file mode 100644 index 0000000..f7381c2 --- /dev/null +++ b/src/api/challenge-payments/challenge-payments.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ChallengePaymentsController } from './challenge-payments.controller'; +import { ChallengePaymentsService } from './challenge-payments.service'; +import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; + +@Module({ + imports: [TopcoderModule], + controllers: [ChallengePaymentsController], + providers: [ChallengePaymentsService], +}) +export class ChallengePaymentsModule {} diff --git a/src/api/challenge-payments/challenge-payments.service.ts b/src/api/challenge-payments/challenge-payments.service.ts new file mode 100644 index 0000000..828762a --- /dev/null +++ b/src/api/challenge-payments/challenge-payments.service.ts @@ -0,0 +1,300 @@ +import { + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { PrismaService } from 'src/shared/global/prisma.service'; +import { TopcoderM2MService } from 'src/shared/topcoder/topcoder-m2m.service'; +import { Logger } from 'src/shared/global'; +import { ENV_CONFIG } from 'src/config'; +import { ChallengeResource, ResourceRole } from '../challenges/models/challenge'; +import { Prisma, winnings, payment } from '@prisma/client'; + +type Auth0User = Record; + +interface ListPaymentsParams { + challengeId: string; + requestUserId?: string; + isMachineToken: boolean; + winnerOnly: boolean; + auth0User?: Auth0User; +} + +interface SerializedPaymentDetail { + id: string; + netAmount: string; + grossAmount: string; + totalAmount: string; + installmentNumber: number; + status: string; + currency: string; + datePaid: string | null; +} + +interface SerializedWinning { + id: string; + type: string; + handle: string; + winnerId: string; + origin: string; + category: string; + title?: string; + description: string; + externalId: string; + attributes: Record; + details: SerializedPaymentDetail[]; + createdAt: string; + releaseDate: string; + datePaid: string | null; +} + +interface PaginatedResponse { + winnings: SerializedWinning[]; + pagination: { + totalItems: number; + totalPages: number; + pageSize: number; + currentPage: number; + }; +} + +const PRIVILEGED_ROLES = new Set([ + 'payment admin', + 'payment editor', + 'payment viewer', + 'administrator', +]); + +@Injectable() +export class ChallengePaymentsService { + private readonly logger = new Logger(ChallengePaymentsService.name); + private readonly tcApiBase = ENV_CONFIG.TOPCODER_API_V6_BASE_URL; + + constructor( + private readonly prisma: PrismaService, + private readonly topcoderM2M: TopcoderM2MService, + ) {} + + async listChallengePayments(params: ListPaymentsParams): Promise { + const { + challengeId, + requestUserId, + isMachineToken, + winnerOnly, + auth0User, + } = params; + + if (!isMachineToken && !requestUserId) { + throw new UnauthorizedException('Authenticated user is missing required identifier'); + } + + let allowAllForChallenge = isMachineToken || this.hasPrivilegedRole(auth0User); + let winnerFilter: string | undefined; + + if (!allowAllForChallenge && requestUserId) { + if (winnerOnly) { + winnerFilter = requestUserId; + } + + try { + const isCopilot = await this.isCopilotForChallenge(challengeId, requestUserId); + if (isCopilot) { + allowAllForChallenge = true; + } + } catch (error) { + this.logger.warn( + `Failed to verify copilot status for user ${requestUserId} on challenge ${challengeId}`, + error instanceof Error ? error.message : error, + ); + } + + if (!allowAllForChallenge) { + winnerFilter = requestUserId; + } + } + + const winnings = await this.fetchWinnings(challengeId, allowAllForChallenge ? undefined : winnerFilter); + return this.serializeResponse(challengeId, winnings); + } + + private hasPrivilegedRole(auth0User?: Auth0User): boolean { + if (!auth0User) { + return false; + } + + const roles = this.extractRoles(auth0User); + return roles.some((role) => PRIVILEGED_ROLES.has(role.toLowerCase())); + } + + private extractRoles(auth0User: Auth0User): string[] { + const roles: string[] = []; + + Object.entries(auth0User).forEach(([key, value]) => { + if (!key.match(/\/roles$/i)) { + return; + } + + if (Array.isArray(value)) { + value.forEach((role) => { + if (role) { + roles.push(String(role)); + } + }); + } else if (value) { + roles.push(String(value)); + } + }); + + const claimRoles = auth0User['roles']; + if (Array.isArray(claimRoles)) { + claimRoles + .filter(Boolean) + .forEach((role) => roles.push(String(role))); + } else if (typeof claimRoles === 'string') { + roles.push(claimRoles); + } + + return roles; + } + + private async isCopilotForChallenge( + challengeId: string, + userId: string, + ): Promise { + const resourcesUrl = `${this.tcApiBase}/resources?challengeId=${challengeId}&memberId=${userId}`; + const resourceRolesUrl = `${this.tcApiBase}/resource-roles`; + + const [resources, resourceRoles] = await Promise.all([ + this.topcoderM2M.m2mFetch(resourcesUrl), + this.topcoderM2M.m2mFetch(resourceRolesUrl), + ]); + + if (!Array.isArray(resources) || resources.length === 0) { + return false; + } + + const copilotRoleIds = new Set( + resourceRoles + ?.filter((role) => + role?.name ? role.name.toLowerCase().includes('copilot') : false, + ) + .map((role) => role.id), + ); + + if (copilotRoleIds.size === 0) { + return false; + } + + return resources.some((resource) => + copilotRoleIds.has(resource.roleId), + ); + } + + private async fetchWinnings( + challengeId: string, + winnerId?: string, + ): Promise<(winnings & { payment: payment[] })[]> { + const where: Prisma.winningsWhereInput = { + external_id: challengeId, + type: 'PAYMENT', + }; + + if (winnerId) { + where.winner_id = winnerId; + } + + return this.prisma.winnings.findMany({ + where, + include: { + payment: { + orderBy: [ + { installment_number: 'asc' }, + { created_at: 'asc' }, + ], + }, + }, + orderBy: [{ created_at: 'desc' }], + }); + } + + private serializeResponse( + challengeId: string, + winnings: Array, + ): PaginatedResponse { + const serialized: SerializedWinning[] = winnings.map((winning) => { + const firstPayment = winning.payment?.[0]; + const details = (winning.payment || []).map((detail) => + this.serializePayment(detail), + ); + + return { + id: winning.winning_id, + type: 'PAYMENT', + handle: '', + winnerId: winning.winner_id, + origin: '', + category: winning.category ?? 'CONTEST_PAYMENT', + title: winning.title ?? undefined, + description: winning.description ?? '', + externalId: winning.external_id ?? challengeId, + attributes: { url: '' }, + details, + createdAt: + this.toIsoString(winning.created_at) ?? + this.toIsoString(new Date()), + releaseDate: + this.toIsoString(firstPayment?.release_date ?? null) ?? + this.toIsoString(new Date()), + datePaid: this.toIsoString( + (firstPayment?.date_paid as Date | null) ?? null, + ), + }; + }); + + return { + winnings: serialized, + pagination: { + totalItems: serialized.length, + totalPages: 1, + pageSize: serialized.length, + currentPage: 1, + }, + }; + } + + private serializePayment(detail: payment): SerializedPaymentDetail { + return { + id: detail.payment_id, + netAmount: this.decimalToString(detail.net_amount), + grossAmount: this.decimalToString(detail.gross_amount), + totalAmount: this.decimalToString(detail.total_amount), + installmentNumber: detail.installment_number ?? 1, + status: detail.payment_status ?? 'OWED', + currency: detail.currency ?? 'USD', + datePaid: this.toIsoString(detail.date_paid), + }; + } + + private decimalToString(value?: Prisma.Decimal | null): string { + if (value === null || value === undefined) { + return '0'; + } + + if (typeof value === 'object' && 'toString' in value) { + return value.toString(); + } + + return String(value); + } + + private toIsoString(value?: Date | null): string | null { + if (!value) { + return null; + } + + try { + return new Date(value).toISOString(); + } catch { + return null; + } + } +} diff --git a/src/api/challenges/challenges.controller.ts b/src/api/challenges/challenges.controller.ts new file mode 100644 index 0000000..90dd263 --- /dev/null +++ b/src/api/challenges/challenges.controller.ts @@ -0,0 +1,63 @@ +import { Controller, Post, Param, HttpCode, HttpStatus } from '@nestjs/common'; +import { + ApiOperation, + ApiParam, + ApiTags, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { UserInfo } from 'src/dto/user.type'; +import { M2mScope } from 'src/core/auth/auth.constants'; +import { AllowedM2mScope, M2M, User } from 'src/core/auth/decorators'; +import { ResponseDto, ResponseStatusType } from 'src/dto/api-response.dto'; +import { ChallengesService } from './challenges.service'; + +@ApiTags('Challenges') +@Controller('/challenges') +@ApiBearerAuth() +export class ChallengesController { + constructor( + private readonly challengesService: ChallengesService, + ) {} + + @Post('/:challengeId') + @M2M() + @AllowedM2mScope(M2mScope.CreatePayments) + @ApiOperation({ + summary: 'Create winning with payments.', + description: 'User must have "create:payments" scope to access.', + }) + @ApiParam({ + name: 'challengeId', + description: 'The ID of the challenge', + example: '2ccba36d-8db7-49da-94c9-b6c5b7bf47fb', + }) + @ApiResponse({ + status: 201, + description: 'Create winnings successfully.', + type: ResponseDto, + }) + @HttpCode(HttpStatus.CREATED) + async createWinnings( + @Param('challengeId') challengeId: string, + @User() user: UserInfo, + ): Promise> { + const result = new ResponseDto(); + + try { + await this.challengesService.generateChallengePayments( + challengeId, + user.id, + ); + result.status = ResponseStatusType.SUCCESS; + } catch (e) { + result.error = { + ...e, + message: e.message, + }; + result.status = ResponseStatusType.ERROR; + } + + return result; + } +} diff --git a/src/api/challenges/challenges.module.ts b/src/api/challenges/challenges.module.ts new file mode 100644 index 0000000..3f0dc62 --- /dev/null +++ b/src/api/challenges/challenges.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; +import { ChallengesService } from './challenges.service'; +import { ChallengesController } from './challenges.controller'; +import { WinningsModule } from '../winnings/winnings.module'; +import { WinningsRepository } from '../repository/winnings.repo'; + +@Module({ + imports: [TopcoderModule, WinningsModule], + controllers: [ChallengesController], + providers: [ + ChallengesService, + WinningsRepository, + ], +}) +export class ChallengesModule {} diff --git a/src/api/challenges/challenges.service.ts b/src/api/challenges/challenges.service.ts new file mode 100644 index 0000000..6291d7d --- /dev/null +++ b/src/api/challenges/challenges.service.ts @@ -0,0 +1,518 @@ +import { + includes, + isEmpty, + find, + camelCase, + groupBy, + orderBy, + uniqBy, +} from 'lodash'; +import { ConflictException, Injectable } from '@nestjs/common'; +import { ENV_CONFIG } from 'src/config'; +import { Logger } from 'src/shared/global'; +import { + Challenge, + ChallengeResource, + ChallengeReview, + Prize, + ResourceRole, + Winner, +} from './models'; +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 { + WinningRequestDto, + WinningsCategory, + WinningsType, +} from 'src/dto/winning.dto'; +import { WinningsRepository } from '../repository/winnings.repo'; +import { PrismaService } from 'src/shared/global/prisma.service'; + +interface PaymentPayload { + handle: string; + amount: number; + userId: string; + type: WinningsCategory; + description?: string; +} + +const placeToOrdinal = (place: number) => { + if (place === 1) return '1st'; + if (place === 2) return '2nd'; + if (place === 3) return '3rd'; + + return `${place}th`; +}; + +const { TOPCODER_API_V6_BASE_URL: TC_API_BASE, TGBillingAccounts } = ENV_CONFIG; + +@Injectable() +export class ChallengesService { + private readonly logger = new Logger(ChallengesService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly m2MService: TopcoderM2MService, + private readonly baService: BillingAccountsService, + private readonly winningsService: WinningsService, + private readonly winningsRepo: WinningsRepository, + ) {} + + async getChallenge(challengeId: string) { + const requestUrl = `${TC_API_BASE}/challenges/${challengeId}`; + + try { + const challenge = await this.m2MService.m2mFetch(requestUrl); + return challenge; + } catch (e) { + this.logger.error( + `Challenge ${challengeId} details couldn't be fetched!`, + e, + ); + } + } + + async getChallengeReviews(challengeId: string) { + const requestUrl = `${TC_API_BASE}/reviews?challengeId=${challengeId}&status=COMPLETED&thin=true&perPage=9999`; + + try { + const resposne = await this.m2MService.m2mFetch<{ + data: ChallengeReview[]; + }>(requestUrl); + return resposne.data; + } catch (e) { + this.logger.error( + `Challenge reviews couldn't be fetched for challenge ${challengeId}!`, + e.message, + e.status, + ); + } + } + + async getChallengeResources(challengeId: string) { + try { + const resources = await this.m2MService.m2mFetch( + `${TC_API_BASE}/resources?challengeId=${challengeId}`, + ); + const resourceRoles = await this.m2MService.m2mFetch( + `${TC_API_BASE}/resource-roles`, + ); + + const rolesMap = resourceRoles.reduce( + (map, role) => { + map[role.id] = camelCase(role.name); + return map; + }, + {} as { [key: string]: string }, + ); + + return groupBy(resources, (r) => rolesMap[r.roleId]) as { + [role: string]: ChallengeResource[]; + }; + } catch (e) { + this.logger.error( + `Challenge resources for challenge ${challengeId} couldn't be fetched!`, + e, + ); + } + } + + generateWinnersPayments( + challenge: Challenge, + winners: Winner[], + prizes: Prize[], + type?: WinningsCategory, + ): PaymentPayload[] { + const isCancelledFailedReview = + challenge.status.toLowerCase() === + ChallengeStatuses.CancelledFailedReview.toLowerCase(); + + if (isCancelledFailedReview) { + return []; + } + + return winners.map((winner) => ({ + handle: winner.handle, + amount: prizes[winner.placement - 1].value, + userId: winner.userId.toString(), + type: + type ?? + (challenge.task.isTask + ? WinningsCategory.TASK_PAYMENT + : WinningsCategory.CONTEST_PAYMENT), + description: + challenge.type === 'Task' + ? challenge.name + : `${challenge.name} - ${type === WinningsCategory.CONTEST_CHECKPOINT_PAYMENT ? 'Checkpoint ' : ''}${placeToOrdinal(winner.placement)} Place`, + })); + } + + generateCheckpointWinnersPayments(challenge: Challenge): PaymentPayload[] { + const { prizeSets, checkpointWinners } = challenge; + + // generate placement payments + const checkpointPrizes = orderBy( + find(prizeSets, { type: 'CHECKPOINT' })?.prizes, + 'value', + 'desc', + ); + + if ((checkpointPrizes?.length ?? 0) < (checkpointWinners?.length ?? 0)) { + throw new Error( + 'Task has incorrect number of checkpoint prizes! There are more checkpoint winners than checkpoint prizes!', + ); + } + + if (!checkpointPrizes?.length) { + return []; + } + + return this.generateWinnersPayments( + challenge, + checkpointWinners, + checkpointPrizes, + WinningsCategory.CONTEST_CHECKPOINT_PAYMENT, + ); + } + + generatePlacementWinnersPayments(challenge: Challenge): PaymentPayload[] { + const { prizeSets, winners } = challenge; + + // generate placement payments + const placementPrizes = orderBy( + find(prizeSets, { type: 'PLACEMENT' })?.prizes, + 'value', + 'desc', + ); + + if (placementPrizes.length < winners.length) { + throw new Error( + 'Task has incorrect number of placement prizes! There are more winners than prizes!', + ); + } + + return this.generateWinnersPayments(challenge, winners, placementPrizes); + } + + generateCopilotPayment( + 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) { + return []; + } + + if (!copilots?.length) { + throw new Error('Task has a copilot prize but no copilot assigned!'); + } + + return copilots.map((copilot) => ({ + handle: copilot.memberHandle, + amount: copilotPrizes[0].value, + userId: copilot.memberId.toString(), + type: WinningsCategory.COPILOT_PAYMENT, + description: `${challenge.name} - Copilot payment`, + })); + } + + async generateReviewersPayments( + challenge: Challenge, + reviewers: ChallengeResource[], + ): Promise { + const placementPrizes = orderBy( + find(challenge.prizeSets, { type: 'PLACEMENT' })?.prizes, + 'value', + 'desc', + ); + + // generate reviewer payments + const firstPlacePrize = placementPrizes?.[0]?.value ?? 0; + const hasMemberReviewers = find(challenge.reviewers, { + isMemberReview: true, + }); + + const challengeReviews = await this.getChallengeReviews(challenge.id); + + if ( + !hasMemberReviewers || + !reviewers?.length || + !challengeReviews?.length + ) { + return []; + } + + // For each challenge resource reviewer (can be main reviewer, approver, screener, etc) + // we get the reviewer's reviews + // and group them by phaseId + // based on the phaseId, we're fetching the correct challenge reviewer type (which has assigned payments coefficients) + // then we create the reviewe's payments for each phase based on the number of reviews done on each phase and the type of challenge reviewer assigned + return reviewers + .map((reviewer) => { + // Find all reviews that were performed by this reviewer (case-insensitive match) + const reviews = challengeReviews + .filter( + (r) => + r.reviewerHandle.toLowerCase() === + reviewer.memberHandle.toLowerCase(), + ) + .map((r) => { + const challengePhase = find(challenge.phases, { id: r.phaseId }); + + if (!challengePhase) { + throw new Error( + `Failed to find challenge phase for review phase: ${r.phaseName} (${r.phaseId})`, + ); + } + + return { + ...r, + // Find the corresponding phase object in the challenge definition using its id + phaseId: challengePhase?.phaseId, + }; + }); + + // Group the reviews by their associated phaseId + return Object.entries(groupBy(reviews, 'phaseId')).map( + ([phaseId, phaseReviews]) => { + // Find the reviewer entry in the challenge's reviewer list for this phase + // (be sure to exclude ai reviews) + const challengeReviewer = find(challenge.reviewers, { + isMemberReview: true, + phaseId, + }); + + if (!challengeReviewer) { + throw new Error( + `Failed to find challenge reviewer for phase: ${phaseReviews[0].phaseName} (${phaseId})`, + ); + } + + return { + handle: reviewer.memberHandle, + userId: reviewer.memberId.toString(), + amount: Math.ceil( + (challengeReviewer.fixedAmount ?? 0) + + (challengeReviewer.baseCoefficient ?? 0) * firstPlacePrize + + (challengeReviewer.incrementalCoefficient ?? 0) * + firstPlacePrize * + phaseReviews.length, + ), + type: WinningsCategory.REVIEW_BOARD_PAYMENT, + description: `${challenge.name} - ${phaseReviews[0].phaseName}`, + }; + }, + ); + }) + .flat(); + } + + async getChallengePayments(challenge: Challenge) { + this.logger.log( + `Generating payments for challenge ${challenge.name} (${challenge.id}).`, + ); + + const challengeResources = await this.getChallengeResources(challenge.id); + + if (!challengeResources || isEmpty(challengeResources)) { + throw new Error('Missing challenge resources!'); + } + + const winnersPayments = this.generatePlacementWinnersPayments(challenge); + const checkpointPayments = + this.generateCheckpointWinnersPayments(challenge); + const copilotPayments = this.generateCopilotPayment( + challenge, + challengeResources.copilot, + ); + + let reviewersPayments: PaymentPayload[] = []; + try { + reviewersPayments = await this.generateReviewersPayments( + challenge, + uniqBy( + [ + ...(challengeResources.iterativeReviewer ?? []), + ...(challengeResources.reviewer ?? []), + ...(challengeResources.checkpointScreener ?? []), + ...(challengeResources.checkpointReviewer ?? []), + ...(challengeResources.screener ?? []), + ...(challengeResources.approver ?? []), + ], + 'memberId', + ), + ); + } catch (error) { + this.logger.error( + `Failed to generate reviewers payments for challenge ${challenge.id}!`, + error.message, + ); + } + + const payments: PaymentPayload[] = [ + ...winnersPayments, + ...checkpointPayments, + ...copilotPayments, + ...reviewersPayments, + ]; + + const totalAmount = payments.reduce( + (sum, payment) => sum + payment.amount, + 0, + ); + return payments.map((payment) => ({ + winnerId: payment.userId.toString(), + type: WinningsType.PAYMENT, + 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: 'USD', + billingAccount: `${challenge.billing.billingAccountId}`, + challengeFee: totalAmount * challenge.billing.markup, + }, + ], + attributes: { + billingAccountId: challenge.billing.billingAccountId, + payroll: includes( + TGBillingAccounts, + parseInt(challenge.billing.billingAccountId), + ), + }, + })); + } + + private async createPayments(challenge: Challenge, userId: string) { + const existingPayments = ( + await this.winningsRepo.searchWinnings({ + externalIds: [challenge.id], + } as WinningRequestDto) + )?.data?.winnings; + + if (existingPayments?.length > 0) { + this.logger.log( + `Payments already exist for challenge ${challenge.id}, skipping payment generation`, + ); + throw new Error( + `Payments already exist for challenge ${challenge.id}, skipping payment generation`, + ); + } + + const paymentTypes = [ + ...new Set( + challenge.prizeSets + .map((set) => set.prizes.map((prize) => prize.type)) + .flat(), + ), + ]; + const isRewardsPayment = paymentTypes.some((type) => type !== 'USD'); + + if (isRewardsPayment) { + this.logger.log( + `Rewards system detected: ${paymentTypes.join(', ')}. Skipping payments generation for challenge ${challenge.name} (${challenge.id}).`, + ); + return; + } + + const payments = await this.getChallengePayments(challenge); + const totalAmount = payments.reduce( + (sum, payment) => sum + payment.details[0].totalAmount, + 0, + ); + + const baValidation = { + challengeId: challenge.id, + billingAccountId: +challenge.billing.billingAccountId, + markup: challenge.billing.markup, + status: challenge.status, + totalPrizesInCents: totalAmount * 100, + }; + + if (challenge.billing?.clientBillingRate != null) { + baValidation.markup = challenge.billing.clientBillingRate; + } + + await Promise.all( + payments.map(async (p) => { + try { + await this.winningsService.createWinningWithPayments(p, userId); + } catch (e) { + this.logger.log( + `Failed to create winnings payment for user ${p.winnerId}!`, + e, + ); + } + }), + ); + + this.logger.log('Task Completed. locking consumed budget', baValidation); + await this.baService.lockConsumeAmount(baValidation); + } + + async generateChallengePayments(challengeId: string, userId: string) { + const challenge = await this.getChallenge(challengeId); + + if (!challenge) { + throw new Error('Challenge not found!'); + } + + const allowedStatuses = [ + ChallengeStatuses.Completed.toLowerCase(), + ChallengeStatuses.CancelledFailedReview.toLowerCase(), + ]; + + if (!allowedStatuses.includes(challenge.status.toLowerCase())) { + throw new Error("Challenge isn't in a payable status!"); + } + + // need to read for update (LOCK the rows) + try { + await this.prisma.challenge_lock.create({ + data: { external_id: challenge.id }, + }); + } catch (err: any) { + if (err.code === 'P2002') { + this.logger.log(`Challenge Lock already acquired for ${challenge.id}`); + // P2002 = unique constraint failed → lock already exists + throw new ConflictException( + `Challenge Lock already acquired for ${challenge.id}`, + ); + } + throw err; + } + + try { + await this.createPayments(challenge, userId); + } catch (error) { + if (error.message.includes('Lock already acquired')) { + throw new ConflictException( + 'Another payment operation is in progress.', + ); + } else { + throw error; + } + } finally { + await this.prisma.challenge_lock + .deleteMany({ + where: { external_id: challenge.id }, + }) + .catch(() => { + // swallow errors if lock was already released + }); + } + } +} diff --git a/src/api/challenges/models/challenge.ts b/src/api/challenges/models/challenge.ts new file mode 100644 index 0000000..635b057 --- /dev/null +++ b/src/api/challenges/models/challenge.ts @@ -0,0 +1,162 @@ +export interface Challenge { + id: string; + name: string; + description: string; + descriptionFormat: string; + projectId: number; + typeId: string; + trackId: string; + timelineTemplateId: string; + currentPhaseNames: string[]; + wiproAllowed: boolean; + tags: string[]; + groups: string[]; + submissionStartDate: string; + submissionEndDate: string; + registrationStartDate: string; + registrationEndDate: string; + startDate: string; + status: string; + createdBy: string; + updatedBy: string; + metadata: MetadataItem[]; + phases: ChallengePhase[]; + discussions: Discussion[]; + events: any[]; // You can replace `any` with the appropriate structure if available + prizeSets: PrizeSet[]; + reviewers: Reviewer[]; // Replace with type if available + terms: any[]; // Replace with type if available + skills: Skill[]; + attachments: any[]; // Replace with type if available + track: string; + type: string; + legacy: Legacy; + billing: Billing; + task: Task; + created: string; + updated: string; + overview: PrizeOverview; + winners: Winner[]; + checkpointWinners: Winner[]; + numOfSubmissions: number; + numOfCheckpointSubmissions: number; + numOfRegistrants: number; +} + +export interface MetadataItem { + name: string; + value: string; +} + +export interface ChallengePhase { + id: string; + phaseId: string; + name: string; + description: string; + isOpen: boolean; + duration: number; + scheduledStartDate: string; + scheduledEndDate: string; + predecessor?: string; + constraints: any[]; // Replace with constraint type if needed +} + +export interface Discussion { + name: string; + type: string; + provider: string; + id: string; + options: any[]; // Replace with option type if needed +} + +export interface PrizeSet { + type: string; + prizes: Prize[]; +} + +export interface Prize { + type: string; + value: number; +} + +export interface Skill { + id: string; + name: string; + category: SkillCategory; +} + +export interface SkillCategory { + id: string; + name: string; +} + +export interface Legacy { + reviewType: string; + confidentialityType: string; + directProjectId: number; + isTask: boolean; + useSchedulingAPI: boolean; + pureV5Task: boolean; + pureV5: boolean; + selfService: boolean; +} + +export interface Billing { + billingAccountId: string; + markup: number; + clientBillingRate?: number; +} + +export interface Task { + isTask: boolean; + isAssigned: boolean; +} + +export interface PrizeOverview { + totalPrizes: number; + type: string; +} + +export interface Winner { + userId: number; + handle: string; + placement: number; +} + +export interface Reviewer { + scorecardId: string; + isMemberReview: boolean; + memberReviewerCount?: number; + phaseId: string; + baseCoefficient?: number; + incrementalCoefficient?: number; + fixedAmount?: number; +} + +export interface ChallengeResource { + memberId: string; + memberHandle: string; + roleId: string; +} + +export interface ResourceRole { + id: string; + name: string; +} + +export interface ChallengeReview { + id: string; + resourceId: string; + phaseId: string; + submissionId: string; + scorecardId: string; + committed: boolean; + finalScore: number; + initialScore: number; + typeId: string; + status: string; + reviewDate: string; + reviewerHandle: string; + phaseName: string; + submitterHandle: string; +} diff --git a/src/api/challenges/models/index.ts b/src/api/challenges/models/index.ts new file mode 100644 index 0000000..6b0a158 --- /dev/null +++ b/src/api/challenges/models/index.ts @@ -0,0 +1 @@ +export * from './challenge'; diff --git a/src/api/repository/identity-verification.repo.ts b/src/api/repository/identity-verification.repo.ts index 4c1fd92..6985aa6 100644 --- a/src/api/repository/identity-verification.repo.ts +++ b/src/api/repository/identity-verification.repo.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { verification_status } from '@prisma/client'; +import { Prisma, verification_status } from '@prisma/client'; import { PrismaService } from 'src/shared/global/prisma.service'; @Injectable() @@ -12,14 +12,18 @@ export class IdentityVerificationRepository { * @param userId - The unique identifier of the user. * @returns A promise that resolves to `true` if the user has at least one active identity verification association, otherwise `false`. */ - async completedIdentityVerification(userId: string): Promise { - const count = - await this.prisma.user_identity_verification_associations.count({ - where: { - user_id: userId, - verification_status: verification_status.ACTIVE, - }, - }); + async completedIdentityVerification( + userId: string, + tx?: Prisma.TransactionClient, + ): Promise { + const count = await ( + tx || this.prisma + ).user_identity_verification_associations.count({ + where: { + user_id: userId, + verification_status: verification_status.ACTIVE, + }, + }); return count > 0; } diff --git a/src/api/repository/paymentMethod.repo.ts b/src/api/repository/paymentMethod.repo.ts index bf61af1..92a292f 100644 --- a/src/api/repository/paymentMethod.repo.ts +++ b/src/api/repository/paymentMethod.repo.ts @@ -1,5 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { payment_method_status, user_payment_methods } from '@prisma/client'; +import { + payment_method_status, + Prisma, + user_payment_methods, +} from '@prisma/client'; import { PrismaService } from 'src/shared/global/prisma.service'; @Injectable() @@ -14,14 +18,16 @@ export class PaymentMethodRepository { */ async getConnectedPaymentMethod( userId: string, + tx?: Prisma.TransactionClient, ): Promise { - const connectedUserPaymentMethod = - await this.prisma.user_payment_methods.findFirst({ - where: { - user_id: userId, - status: payment_method_status.CONNECTED, - }, - }); + const connectedUserPaymentMethod = await ( + tx || this.prisma + ).user_payment_methods.findFirst({ + where: { + user_id: userId, + status: payment_method_status.CONNECTED, + }, + }); return connectedUserPaymentMethod; } diff --git a/src/api/repository/taxForm.repo.ts b/src/api/repository/taxForm.repo.ts index 59df1db..fb39884 100644 --- a/src/api/repository/taxForm.repo.ts +++ b/src/api/repository/taxForm.repo.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { tax_form_status } from '@prisma/client'; +import { Prisma, tax_form_status } from '@prisma/client'; import { PrismaService } from 'src/shared/global/prisma.service'; @Injectable() @@ -12,8 +12,11 @@ export class TaxFormRepository { * @param userId user id * @returns true if user has active tax form */ - async hasActiveTaxForm(userId: string): Promise { - const count = await this.prisma.user_tax_form_associations.count({ + async hasActiveTaxForm( + userId: string, + tx?: Prisma.TransactionClient, + ): Promise { + const count = await (tx || this.prisma).user_tax_form_associations.count({ where: { user_id: userId, tax_form_status: tax_form_status.ACTIVE, diff --git a/src/api/repository/winnings.repo.ts b/src/api/repository/winnings.repo.ts index 2095d02..7710ab3 100644 --- a/src/api/repository/winnings.repo.ts +++ b/src/api/repository/winnings.repo.ts @@ -25,7 +25,7 @@ export class WinningsRepository { constructor(private readonly prisma: PrismaService) {} - private generateFilterDate(date: DateFilterType) { + private generateFilterDate(date?: DateFilterType) { let filterDate: object | undefined; const currentDay = new Date(new Date().setHours(0, 0, 0, 0)); @@ -54,11 +54,11 @@ export class WinningsRepository { } private getWinningsQueryFilters( - type: string, - status: string, - winnerIds: string[] | undefined, - externalIds: string[] | undefined, - date: DateFilterType, + type?: string, + status?: string, + winnerIds?: string[], + externalIds?: string[], + date?: DateFilterType, ): Prisma.winningsFindManyArgs['where'] { return { winner_id: winnerIds @@ -99,7 +99,7 @@ export class WinningsRepository { } private getOrderByWithWinnerId( - sortBy: string, + sortBy: string | undefined, sortOrder: 'asc' | 'desc', externalIds?: boolean, ) { @@ -162,7 +162,7 @@ export class WinningsRepository { winnerIds = [searchProps.winnerId]; } else if (searchProps.winnerIds) { winnerIds = [...searchProps.winnerIds]; - } else if (searchProps.externalIds?.length > 0) { + } else if ((searchProps.externalIds?.length ?? 0) > 0) { externalIds = searchProps.externalIds; } diff --git a/src/api/winnings/winnings.module.ts b/src/api/winnings/winnings.module.ts index c463ca0..69210a8 100644 --- a/src/api/winnings/winnings.module.ts +++ b/src/api/winnings/winnings.module.ts @@ -19,5 +19,8 @@ import { IdentityVerificationRepository } from '../repository/identity-verificat PaymentMethodRepository, IdentityVerificationRepository, ], + exports: [ + WinningsService, + ] }) export class WinningsModule {} diff --git a/src/api/winnings/winnings.service.ts b/src/api/winnings/winnings.service.ts index 903fd96..32e70e3 100644 --- a/src/api/winnings/winnings.service.ts +++ b/src/api/winnings/winnings.service.ts @@ -56,7 +56,7 @@ export class WinningsService { } this.logger.debug( - `Member info retrieved successfully for user handle: ${userId}`, + `Member info retrieved successfully for user: ${userId}`, { member }, ); @@ -88,8 +88,13 @@ export class WinningsService { } } - private async setPayrollPaymentMethod(userId: string) { - const payrollPaymentMethod = await this.prisma.payment_method.findFirst({ + private async setPayrollPaymentMethod( + userId: string, + tx?: Prisma.TransactionClient, + ) { + const payrollPaymentMethod = await ( + tx || this.prisma + ).payment_method.findFirst({ where: { payment_method_type: 'Wipro Payroll', }, @@ -101,7 +106,7 @@ export class WinningsService { } if ( - await this.prisma.user_payment_methods.findFirst({ + await (tx || this.prisma).user_payment_methods.findFirst({ where: { user_id: userId, payment_method_id: payrollPaymentMethod.payment_method_id, @@ -142,7 +147,7 @@ export class WinningsService { const result = new ResponseDto(); this.logger.debug( - `Creating winning with payments for user ${userId}`, + `Creating winning with payments for user ${body.winnerId}`, body, ); @@ -180,13 +185,18 @@ export class WinningsService { const hasActiveTaxForm = await this.taxFormRepo.hasActiveTaxForm( body.winnerId, + tx, ); const hasConnectedPaymentMethod = Boolean( - await this.paymentMethodRepo.getConnectedPaymentMethod(body.winnerId), + await this.paymentMethodRepo.getConnectedPaymentMethod( + body.winnerId, + tx, + ), ); const isIdentityVerified = await this.identityVerificationRepo.completedIdentityVerification( - userId, + body.winnerId, + tx, ); for (const detail of body.details || []) { @@ -213,7 +223,7 @@ export class WinningsService { `Payroll payment detected. Setting payment status to PAID for user ${body.winnerId}`, ); paymentModel.payment_status = PaymentStatus.PAID; - await this.setPayrollPaymentMethod(body.winnerId); + await this.setPayrollPaymentMethod(body.winnerId, tx); } winningModel.payment.create.push(paymentModel); @@ -223,7 +233,7 @@ export class WinningsService { } this.logger.debug('Attempting to create winning with nested payments.'); - const createdWinning = await this.prisma.winnings.create({ + const createdWinning = await tx.winnings.create({ data: winningModel as any, }); diff --git a/src/api/withdrawal/withdrawal.service.ts b/src/api/withdrawal/withdrawal.service.ts index 3add106..10dc186 100644 --- a/src/api/withdrawal/withdrawal.service.ts +++ b/src/api/withdrawal/withdrawal.service.ts @@ -11,10 +11,7 @@ import { } from '@prisma/client'; import { TrolleyService } from 'src/shared/global/trolley.service'; import { PaymentsService } from 'src/shared/payments'; -import { - TopcoderChallengesService, - WithdrawUpdateData, -} from 'src/shared/topcoder/challenges.service'; +import { TopcoderChallengesService } from 'src/shared/topcoder/challenges.service'; import { TopcoderMembersService } from 'src/shared/topcoder/members.service'; import { BasicMemberInfo, BASIC_MEMBER_FIELDS } from 'src/shared/topcoder'; import { Logger } from 'src/shared/global'; @@ -34,16 +31,6 @@ interface ReleasableWinningRow { datePaid: Date; } -function formatDate(date = new Date()) { - const pad = (n, z = 2) => String(n).padStart(z, '0'); - - return ( - `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + - `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.` + - `${pad(date.getMilliseconds(), 3)}` - ); -} - @Injectable() export class WithdrawalService { private readonly logger = new Logger(WithdrawalService.name); @@ -403,26 +390,6 @@ export class WithdrawalService { throw new Error(errorMsg); } - - try { - for (const winning of winnings) { - const payoutData: WithdrawUpdateData = { - userId: +userId, - status: 'Paid', - datePaid: formatDate(new Date()), - }; - - await this.tcChallengesService.updateLegacyPayments( - winning.externalId as string, - payoutData, - ); - } - } catch (error) { - this.logger.error( - `Failed to update legacy payment while withdrawing for challenge ${error?.message ?? error}`, - error, - ); - } } catch (error) { if (error.code === 'P2010' && error.meta?.code === '55P03') { this.logger.error( diff --git a/src/config/config.env.ts b/src/config/config.env.ts index fc47a24..eb3aa4f 100644 --- a/src/config/config.env.ts +++ b/src/config/config.env.ts @@ -13,14 +13,16 @@ import { export class ConfigEnv { @IsString() @IsOptional() - API_BASE = '/v5/finance'; + API_BASE = '/v6/finance'; - @IsInt() @IsOptional() PORT = 3000; @IsString() - TOPCODER_API_BASE_URL!: string; + TOPCODER_API_V5_BASE_URL!: string; + + @IsString() + TOPCODER_API_V6_BASE_URL!: string; @IsString() AUTH0_M2M_AUDIENCE!: string; @@ -110,4 +112,7 @@ export class ConfigEnv { @IsString() SENDGRID_TEMPLATE_ID_OTP_CODE: string = 'd-2d0ab9f6c9cc4efba50080668a9c35c1'; + + @IsInt({ each: true }) + TGBillingAccounts = [80000062, 80002800]; } diff --git a/src/dto/challenge.dto.ts b/src/dto/challenge.dto.ts new file mode 100644 index 0000000..791ddc3 --- /dev/null +++ b/src/dto/challenge.dto.ts @@ -0,0 +1,17 @@ +export const ChallengeStatuses = { + New: 'New', + Active: 'Active', + Draft: 'Draft', + Approved: 'Approved', + Canceled: 'Canceled', + Completed: 'Completed', + Deleted: 'Deleted', + CancelledFailedReview: 'CANCELLED_FAILED_REVIEW', + CancelledFailedScreening: 'CANCELLED_FAILED_SCREENING', + CancelledZeroSubmissions: 'CANCELLED_ZERO_SUBMISSIONS', + CancelledWinnerUnresponsive: 'CANCELLED_WINNER_UNRESPONSIVE', + CancelledClientRequest: 'CANCELLED_CLIENT_REQUEST', + CancelledRequirementsInfeasible: 'CANCELLED_REQUIREMENTS_INFEASIBLE', + CancelledZeroRegistrations: 'CANCELLED_ZERO_REGISTRATIONS', + CancelledPaymentFailed: 'CANCELLED_PAYMENT_FAILED', +}; diff --git a/src/dto/sort-pagination.dto.ts b/src/dto/sort-pagination.dto.ts index 8fad58e..a1fafe7 100644 --- a/src/dto/sort-pagination.dto.ts +++ b/src/dto/sort-pagination.dto.ts @@ -34,7 +34,7 @@ export class SortPagination { @IsOptional() @IsInt() @Min(1) - limit: number = 10; + limit?: number = 10; @ApiProperty({ description: 'The offset parameter for pagination', @@ -43,7 +43,7 @@ export class SortPagination { @IsOptional() @IsInt() @Min(0) - offset: number = 0; + offset?: number = 0; @ApiProperty({ description: 'The sortBy parameter for sorting', @@ -51,7 +51,7 @@ export class SortPagination { }) @IsOptional() @IsIn(OrderBy) - sortBy: string; + sortBy?: string; @ApiProperty({ description: 'The sort order', @@ -60,5 +60,5 @@ export class SortPagination { }) @IsOptional() @IsEnum(SortOrder) - sortOrder: SortOrder = SortOrder.ASC; + sortOrder?: SortOrder = SortOrder.ASC; } diff --git a/src/dto/winning.dto.ts b/src/dto/winning.dto.ts index 3e7110c..f0fbdfb 100644 --- a/src/dto/winning.dto.ts +++ b/src/dto/winning.dto.ts @@ -127,7 +127,7 @@ export class WinningRequestDto extends SortPagination { @IsOptional() @IsString() @IsNotEmpty() - winnerId: string; + winnerId?: string; @ApiProperty({ description: 'The array of the winner ids', @@ -138,7 +138,7 @@ export class WinningRequestDto extends SortPagination { @ArrayNotEmpty() @IsString({ each: true }) @IsNotEmpty({ each: true }) - winnerIds: string[]; + winnerIds?: string[]; @ApiProperty({ description: 'The array of the external ids', @@ -149,7 +149,7 @@ export class WinningRequestDto extends SortPagination { @ArrayNotEmpty() @IsString({ each: true }) @IsNotEmpty({ each: true }) - externalIds: string[]; + externalIds?: string[]; @ApiProperty({ description: 'The type of winnings category', @@ -158,7 +158,7 @@ export class WinningRequestDto extends SortPagination { }) @IsOptional() @IsEnum(WinningsCategory) - type: WinningsCategory; + type?: WinningsCategory; @ApiProperty({ description: 'The payment status', @@ -167,7 +167,7 @@ export class WinningRequestDto extends SortPagination { }) @IsOptional() @IsEnum(PaymentStatus) - status: PaymentStatus; + status?: PaymentStatus; @ApiProperty({ description: 'The filter date', @@ -176,7 +176,7 @@ export class WinningRequestDto extends SortPagination { }) @IsOptional() @IsEnum(DateFilterType) - date: DateFilterType; + date?: DateFilterType; } export class WinningCreateRequestDto { diff --git a/src/main.ts b/src/main.ts index 57a2450..37ad4f6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,18 +3,12 @@ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { ValidationPipe } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import { ApiModule } from './api/api.module'; import { AppModule } from './app.module'; -import { PaymentProvidersModule } from './api/payment-providers/payment-providers.module'; -import { WebhooksModule } from './api/webhooks/webhooks.module'; import { ENV_CONFIG } from './config'; -import { AdminModule } from './api/admin/admin.module'; -import { UserModule } from './api/user/user.module'; -import { WalletModule } from './api/wallet/wallet.module'; -import { WinningsModule } from './api/winnings/winnings.module'; -import { WithdrawalModule } from './api/withdrawal/withdrawal.module'; import { Logger } from 'src/shared/global'; +const API_DOCS_URL = `${ENV_CONFIG.API_BASE}/api-docs`; + async function bootstrap() { const app = await NestFactory.create(AppModule, { rawBody: true, @@ -83,19 +77,8 @@ async function bootstrap() { in: 'header', }) .build(); - const document = SwaggerModule.createDocument(app, config, { - include: [ - ApiModule, - AdminModule, - UserModule, - WinningsModule, - WithdrawalModule, - WalletModule, - PaymentProvidersModule, - WebhooksModule, - ], - }); - SwaggerModule.setup('/v5/finance/api-docs', app, document); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup(API_DOCS_URL, app, document); // Add an event handler to log uncaught promise rejections and prevent the server from crashing process.on('unhandledRejection', (reason, promise) => { @@ -110,6 +93,8 @@ async function bootstrap() { }); await app.listen(ENV_CONFIG.PORT ?? 3000); + logger.log(`Application is running on: ${await app.getUrl()}`); + logger.log(`Swagger docs available at: ${await app.getUrl()}${API_DOCS_URL}`); } void bootstrap(); diff --git a/src/shared/topcoder/billing-accounts.service.ts b/src/shared/topcoder/billing-accounts.service.ts new file mode 100644 index 0000000..b0ca3bd --- /dev/null +++ b/src/shared/topcoder/billing-accounts.service.ts @@ -0,0 +1,164 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { isNumber, includes } from 'lodash'; +import { ENV_CONFIG } from 'src/config'; +import { ChallengeStatuses } from 'src/dto/challenge.dto'; +import { TopcoderM2MService } from './topcoder-m2m.service'; + +const { TOPCODER_API_V6_BASE_URL, TGBillingAccounts } = ENV_CONFIG; + +interface LockAmountDTO { + challengeId: string; + lockAmount: number; +} +interface ConsumeAmountDTO { + challengeId: string; + consumeAmount: number; + markup?: number; +} + +export interface BAValidation { + challengeId?: string; + billingAccountId?: number; + markup?: number; + prevStatus?: string; + status?: string; + prevTotalPrizesInCents?: number; + totalPrizesInCents: number; +} + +@Injectable() +export class BillingAccountsService { + private readonly logger = new Logger(BillingAccountsService.name); + + constructor(private readonly m2MService: TopcoderM2MService) {} + + async lockAmount(billingAccountId: number, dto: LockAmountDTO) { + this.logger.log('BA validation lock amount:', billingAccountId, dto); + + try { + return await this.m2MService.m2mFetch( + `${TOPCODER_API_V6_BASE_URL}/billing-accounts/${billingAccountId}/lock-amount`, + { + method: 'PATCH', + body: JSON.stringify({ param: dto }), + }, + ); + } catch (err: any) { + this.logger.error( + err.response?.data?.result?.content ?? + 'Failed to lock challenge amount', + ); + throw new Error( + `Budget Error: Requested amount $${dto.lockAmount} exceeds available budget for Billing Account #${billingAccountId}. + Please contact the Topcoder Project Manager for further assistance.`, + ); + } + } + + async consumeAmount(billingAccountId: number, dto: ConsumeAmountDTO) { + this.logger.log('BA validation consume amount:', billingAccountId, dto); + + try { + return await this.m2MService.m2mFetch( + `${TOPCODER_API_V6_BASE_URL}/billing-accounts/${billingAccountId}/consume-amount`, + { + method: 'PATCH', + body: JSON.stringify({ param: dto }), + }, + ); + } catch (err: any) { + this.logger.error( + err.response?.data?.result?.content ?? + 'Failed to consume challenge amount', + err, + ); + throw new Error('Failed to consume challenge amount'); + } + } + + async lockConsumeAmount( + baValidation: BAValidation, + rollback: boolean = false, + ): Promise { + const billingAccountId = baValidation.billingAccountId + ? +baValidation.billingAccountId + : undefined; + if (!isNumber(billingAccountId)) { + this.logger.warn( + "Challenge doesn't have billing account id:", + baValidation, + ); + return; + } + if (includes(TGBillingAccounts, billingAccountId)) { + this.logger.info( + 'Ignore BA validation for Topgear account:', + billingAccountId, + ); + return; + } + + this.logger.log('BA validation:', baValidation); + + const status = baValidation.status?.toLowerCase(); + if ( + status === ChallengeStatuses.Active.toLowerCase() || + status === ChallengeStatuses.Approved.toLowerCase() + ) { + // Update lock amount + const currAmount = baValidation.totalPrizesInCents / 100; + const prevAmount = (baValidation.prevTotalPrizesInCents ?? 0) / 100; + + await this.lockAmount(billingAccountId, { + challengeId: baValidation.challengeId!, + lockAmount: + (rollback ? prevAmount : currAmount) * (1 + baValidation.markup!), + }); + } else if (status === ChallengeStatuses.Completed.toLowerCase()) { + // Note an already completed challenge could still be updated with prizes + const currAmount = baValidation.totalPrizesInCents / 100; + const prevAmount = + baValidation.prevStatus === ChallengeStatuses.Completed + ? (baValidation.prevTotalPrizesInCents ?? 0) / 100 + : 0; + + if (currAmount !== prevAmount) { + await this.consumeAmount(billingAccountId, { + challengeId: baValidation.challengeId!, + consumeAmount: + (rollback ? prevAmount : currAmount) * (1 + baValidation.markup!), + markup: 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!, + lockAmount: rollback ? prevAmount : 0, + }); + } + } + } + } +} diff --git a/src/shared/topcoder/bus.service.ts b/src/shared/topcoder/bus.service.ts index 8397eec..e24c105 100644 --- a/src/shared/topcoder/bus.service.ts +++ b/src/shared/topcoder/bus.service.ts @@ -3,7 +3,7 @@ import { ENV_CONFIG } from 'src/config'; import { TopcoderM2MService } from './topcoder-m2m.service'; import { Logger } from '../global'; -const { TOPCODER_API_BASE_URL } = ENV_CONFIG; +const { TOPCODER_API_V5_BASE_URL: TC_API_BASE } = ENV_CONFIG; @Injectable() export class TopcoderBusService { @@ -43,7 +43,7 @@ export class TopcoderBusService { try { const headers = await this.getHeaders(); - const response = await fetch(`${TOPCODER_API_BASE_URL}/bus/events`, { + const response = await fetch(`${TC_API_BASE}/bus/events`, { method: 'POST', headers, body: JSON.stringify({ diff --git a/src/shared/topcoder/challenges.service.ts b/src/shared/topcoder/challenges.service.ts index fb087df..063a2de 100644 --- a/src/shared/topcoder/challenges.service.ts +++ b/src/shared/topcoder/challenges.service.ts @@ -1,11 +1,7 @@ import { Injectable } from '@nestjs/common'; import { TopcoderM2MService } from './topcoder-m2m.service'; -import { ENV_CONFIG } from 'src/config'; -import { payment_status } from '@prisma/client'; import { Logger } from 'src/shared/global'; -const { TOPCODER_API_BASE_URL } = ENV_CONFIG; - export interface WithdrawUpdateData { userId: number; status: string; @@ -18,77 +14,9 @@ export interface AdminPaymentUpdateData { amount: number; releaseDate: string; } - -const mapStatus = (payoutData: WithdrawUpdateData | AdminPaymentUpdateData) => { - return { - ...payoutData, - status: { - [payment_status.CANCELLED]: 'Cancelled', - [payment_status.FAILED]: 'Failed', - [payment_status.ON_HOLD]: 'OnHold', - [payment_status.ON_HOLD_ADMIN]: 'OnHoldAdmin', - [payment_status.OWED]: 'Owed', - [payment_status.PAID]: 'Paid', - [payment_status.PROCESSING]: 'Processing', - [payment_status.RETURNED]: 'Returned', - }[payoutData.status], - }; -}; - @Injectable() export class TopcoderChallengesService { private readonly logger = new Logger(TopcoderChallengesService.name); constructor(private readonly m2MService: TopcoderM2MService) {} - - async updateLegacyPayments( - challengeId: string, - payoutData: WithdrawUpdateData | AdminPaymentUpdateData, - ) { - const requestData = mapStatus(payoutData); - - let m2mToken: string | undefined; - try { - m2mToken = await this.m2MService.getToken(); - } catch (e) { - this.logger.error( - 'Failed to fetch m2m token for fetching member details!', - e.message ?? e, - ); - } - const requestUrl = `${TOPCODER_API_BASE_URL}/challenges/${challengeId}/legacy-payment`; - - this.logger.debug( - `Updating legacy payment for challenge ${challengeId} with data: ${JSON.stringify(requestData, null, 2)}`, - ); - - try { - const response = await fetch(requestUrl, { - method: 'PATCH', - body: JSON.stringify(requestData), - headers: { - Authorization: `Bearer ${m2mToken}`, - 'Content-Type': 'application/json', - }, - }); - - const jsonResponse: { [key: string]: string } = await response.json(); - - if (response.status > 299) { - throw new Error(jsonResponse.message ?? JSON.stringify(jsonResponse)); - } - - this.logger.debug( - `Response from updating legacy payment for challenge ${challengeId}: ${JSON.stringify(jsonResponse, null, 2)}`, - ); - - return jsonResponse; - } catch (e) { - this.logger.error( - `Failed to update legacy payment for challenge ${challengeId}! Error: ${e?.message ?? e}`, - e, - ); - throw e; - } - } } diff --git a/src/shared/topcoder/members.service.ts b/src/shared/topcoder/members.service.ts index c0bc57f..a7ad113 100644 --- a/src/shared/topcoder/members.service.ts +++ b/src/shared/topcoder/members.service.ts @@ -5,7 +5,7 @@ import { TopcoderM2MService } from './topcoder-m2m.service'; import { ENV_CONFIG } from 'src/config'; import { Logger } from 'src/shared/global'; -const { TOPCODER_API_BASE_URL } = ENV_CONFIG; +const { TOPCODER_API_V6_BASE_URL: TC_API_BASE } = ENV_CONFIG; @Injectable() export class TopcoderMembersService { @@ -27,7 +27,7 @@ export class TopcoderMembersService { // Split the unique user IDs into chunks of 100 to comply with API request limits const requests = chunk(uniqUserIds, 30).map((chunk) => { - const requestUrl = `${TOPCODER_API_BASE_URL}/members?${chunk.map((id) => `userIds[]=${id}`).join('&')}&fields=handle,userId`; + const requestUrl = `${TC_API_BASE}/members?${chunk.map((id) => `userIds[]=${id}`).join('&')}&fields=handle,userId`; return fetch(requestUrl).then( async (response) => (await response.json()) as { handle: string; userId: string }, @@ -77,7 +77,7 @@ export class TopcoderMembersService { e.message ?? e, ); } - const requestUrl = `${TOPCODER_API_BASE_URL}/members/${handle}${fields ? `?fields=${fields.join(',')}` : ''}`; + const requestUrl = `${TC_API_BASE}/members/${handle}${fields ? `?fields=${fields.join(',')}` : ''}`; try { const response = await fetch(requestUrl, { diff --git a/src/shared/topcoder/topcoder-m2m.service.ts b/src/shared/topcoder/topcoder-m2m.service.ts index cd6fcc6..d36d1f1 100644 --- a/src/shared/topcoder/topcoder-m2m.service.ts +++ b/src/shared/topcoder/topcoder-m2m.service.ts @@ -33,6 +33,7 @@ export class TopcoderM2MService { client_secret: ENV_CONFIG.AUTH0_M2M_SECRET, audience: ENV_CONFIG.AUTH0_M2M_AUDIENCE, grant_type: ENV_CONFIG.AUTH0_M2M_GRANT_TYPE, + // fresh_token: true, }), }); @@ -63,4 +64,50 @@ export class TopcoderM2MService { return undefined; } } + + async m2mFetch(url: string | URL, options = {} as RequestInit) { + let m2mToken: string | undefined; + try { + m2mToken = await this.getToken(); + } catch (e) { + this.logger.error( + 'Failed to fetch m2m token!', + e.message ?? e, + ); + } + + if (!m2mToken) { + throw new Error('Failed to fetch m2m token for m2m call!') + } + + // Initialize headers, ensuring Authorization is added + const headers = new Headers(options.headers || {}); + headers.set('Authorization', `Bearer ${m2mToken}`); + + if (!headers.get('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const finalOptions: RequestInit = { + ...options, + headers, + }; + + const response = await fetch(url, finalOptions); + + if (!response.ok) { + // Optional: You could throw a custom error here + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const contentType = response.headers.get('content-type'); + + // Try to parse JSON if content-type is application/json + if (contentType && contentType.includes('application/json')) { + return response.json() as Promise; + } + + // If not JSON, return text + return response.text() as unknown as T; + } } diff --git a/src/shared/topcoder/topcoder.module.ts b/src/shared/topcoder/topcoder.module.ts index 3071b22..2dc37f8 100644 --- a/src/shared/topcoder/topcoder.module.ts +++ b/src/shared/topcoder/topcoder.module.ts @@ -4,6 +4,7 @@ import { TopcoderM2MService } from './topcoder-m2m.service'; import { TopcoderChallengesService } from './challenges.service'; import { TopcoderBusService } from './bus.service'; import { TopcoderEmailService } from './tc-email.service'; +import { BillingAccountsService } from './billing-accounts.service'; @Module({ providers: [ @@ -12,12 +13,15 @@ import { TopcoderEmailService } from './tc-email.service'; TopcoderM2MService, TopcoderBusService, TopcoderEmailService, + BillingAccountsService, ], exports: [ TopcoderChallengesService, TopcoderMembersService, + TopcoderM2MService, TopcoderBusService, TopcoderEmailService, + BillingAccountsService, ], }) export class TopcoderModule {}