diff --git a/package.json b/package.json index 047c87b..eea75be 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,11 @@ "csv": "^6.3.11", "csv-stringify": "^6.5.2", "dotenv": "^16.5.0", + "json-stringify-safe": "^5.0.1", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.2.0", "lodash": "^4.17.21", + "nanoid": "^5.1.5", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "trolleyhq": "^1.1.0", @@ -50,6 +52,7 @@ "@swc/core": "^1.10.7", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/json-stringify-safe": "^5.0.3", "@types/lodash": "^4.17.16", "@types/node": "^22.13.1", "@types/supertest": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fd9f1a..bb6236d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: dotenv: specifier: ^16.5.0 version: 16.5.0 + json-stringify-safe: + specifier: ^5.0.1 + version: 5.0.1 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -50,6 +53,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + nanoid: + specifier: ^5.1.5 + version: 5.1.5 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -90,6 +96,9 @@ importers: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/json-stringify-safe': + specifier: ^5.0.3 + version: 5.0.3 '@types/lodash': specifier: ^4.17.16 version: 4.17.16 @@ -1239,6 +1248,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/json-stringify-safe@5.0.3': + resolution: {integrity: sha512-oNOjRxLfPeYbBSQ60maucaFNqbslVOPU4WWs5t/sHvAh6tyo/CThXSG+E24tEzkgh/fzvxyDrYdOJufgeNy1sQ==} + '@types/jsonwebtoken@9.0.9': resolution: {integrity: sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==} @@ -2692,6 +2704,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2941,6 +2956,11 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + nanoid@5.1.5: + resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -4980,6 +5000,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/json-stringify-safe@5.0.3': {} + '@types/jsonwebtoken@9.0.9': dependencies: '@types/ms': 2.1.0 @@ -6761,6 +6783,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} + json5@2.2.3: {} jsonc-parser@3.3.1: {} @@ -6989,6 +7013,8 @@ snapshots: mute-stream@2.0.0: {} + nanoid@5.1.5: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} diff --git a/prisma/migrations/20250423135831_truncate_payment_methods/migration.sql b/prisma/migrations/20250423135831_truncate_payment_methods/migration.sql index 3eff7e0..8be6a35 100644 --- a/prisma/migrations/20250423135831_truncate_payment_methods/migration.sql +++ b/prisma/migrations/20250423135831_truncate_payment_methods/migration.sql @@ -7,5 +7,11 @@ ALTER TABLE "paypal_payment_method" ALTER COLUMN "user_payment_method_id" DROP N UPDATE payoneer_payment_method SET user_payment_method_id = NULL; UPDATE paypal_payment_method SET user_payment_method_id = NULL; -DELETE FROM user_default_payment_method; -DELETE FROM user_payment_methods; \ No newline at end of file +DROP TABLE user_default_payment_method; + +DELETE FROM user_payment_methods +WHERE payment_method_id NOT IN ( + SELECT payment_method_id + FROM payment_method + WHERE payment_method_type IN ('Wipro Payroll', 'Trolley') +); diff --git a/prisma/migrations/20250520061120_reset_payments_status/migration.sql b/prisma/migrations/20250520061120_reset_payments_status/migration.sql new file mode 100644 index 0000000..9d6e6fd --- /dev/null +++ b/prisma/migrations/20250520061120_reset_payments_status/migration.sql @@ -0,0 +1,34 @@ +-- Update all payments witha "owed" status to "on_hold" for users that don't have a trolley_id +-- and no trolley_recipient_payment_method set. + +UPDATE payment +SET payment_status = 'ON_HOLD' +WHERE winnings_id IN ( + SELECT winnings.winning_id + FROM winnings + LEFT JOIN trolley_recipient ON winnings.winner_id = trolley_recipient.user_id + LEFT JOIN trolley_recipient_payment_method ON trolley_recipient.id = trolley_recipient_payment_method.trolley_recipient_id + WHERE payment.payment_status = 'OWED' AND ( + trolley_recipient.trolley_id IS NULL OR + trolley_recipient_payment_method.id IS NULL + ) +); + +-- To test & see the updated payments: +-- +-- SELECT payment.payment_id, +-- payment.total_amount, +-- payment.installment_number, +-- payment.payment_method_id, +-- payment.version, +-- payment.payment_status, +-- winnings.winner_id, +-- trolley_recipient.trolley_id, +-- trolley_recipient_payment_method.recipient_account_id +-- FROM payment +-- LEFT JOIN winnings ON payment.winnings_id = winnings.winning_id +-- LEFT JOIN trolley_recipient ON winnings.winner_id = trolley_recipient.user_id +-- LEFT JOIN trolley_recipient_payment_method ON trolley_recipient.id = trolley_recipient_payment_method.trolley_recipient_id +-- WHERE payment.payment_status = 'OWED' +-- AND trolley_recipient.trolley_id IS NULL +-- AND trolley_recipient_payment_method.id IS NULL; diff --git a/prisma/migrations/20250602083823_add_payment_challenge_fee/migration.sql b/prisma/migrations/20250602083823_add_payment_challenge_fee/migration.sql new file mode 100644 index 0000000..ff4f96d --- /dev/null +++ b/prisma/migrations/20250602083823_add_payment_challenge_fee/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "payment" ADD COLUMN "challenge_fee" DECIMAL(12,2), +ADD COLUMN "challenge_markup" DECIMAL(12,2); diff --git a/prisma/migrations/20250611095824_add_user_id_verification/migration.sql b/prisma/migrations/20250611095824_add_user_id_verification/migration.sql new file mode 100644 index 0000000..2cd750c --- /dev/null +++ b/prisma/migrations/20250611095824_add_user_id_verification/migration.sql @@ -0,0 +1,13 @@ +-- CreateEnum +CREATE TYPE "verification_status" AS ENUM ('ACTIVE', 'INACTIVE'); + +-- CreateTable +CREATE TABLE "user_identity_verification_associations" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "user_id" VARCHAR(80) NOT NULL, + "verification_id" TEXT NOT NULL, + "date_filed" TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL, + "verification_status" "verification_status" NOT NULL, + + CONSTRAINT "user_identity_verification_associations_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20250618100641_drop_otp_transaction/migration.sql b/prisma/migrations/20250618100641_drop_otp_transaction/migration.sql new file mode 100644 index 0000000..b01266a --- /dev/null +++ b/prisma/migrations/20250618100641_drop_otp_transaction/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `transaction_id` on the `otp` table. All the data in the column will be lost. + - You are about to drop the `transaction` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- AlterTable +ALTER TABLE "otp" DROP COLUMN "transaction_id"; + +-- DropTable +DROP TABLE "transaction"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f380e03..d003137 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,7 +30,6 @@ model otp { email String @db.VarChar(255) otp_hash String @db.VarChar(255) expiration_time DateTime @default(dbgenerated("(CURRENT_TIMESTAMP + '00:05:00'::interval)")) @db.Timestamp(6) - transaction_id String @db.VarChar(255) action_type reference_type created_at DateTime? @default(now()) @db.Timestamp(6) updated_at DateTime? @default(now()) @db.Timestamp(6) @@ -55,6 +54,8 @@ model payment { release_date DateTime? @default(dbgenerated("(CURRENT_TIMESTAMP + '15 days'::interval)")) @db.Timestamp(6) payment_status payment_status? billing_account String @db.VarChar(80) + challenge_markup Decimal? @db.Decimal(12, 2) + challenge_fee Decimal? @db.Decimal(12, 2) payment_method payment_method? @relation(fields: [payment_method_id], references: [payment_method_id], onDelete: NoAction, onUpdate: NoAction) winnings winnings @relation(fields: [winnings_id], references: [winning_id], onDelete: NoAction, onUpdate: NoAction) payment_release_associations payment_release_associations[] @@ -67,7 +68,6 @@ model payment_method { description String? payment payment[] payment_releases payment_releases[] - user_default_payment_method user_default_payment_method[] user_payment_methods user_payment_methods[] } @@ -128,22 +128,6 @@ model reward { winnings winnings @relation(fields: [winnings_id], references: [winning_id], onDelete: NoAction, onUpdate: NoAction) } -model transaction { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - user_id String @db.VarChar(255) - reference_id String @db.Uuid - reference_type reference_type - status transaction_status @default(INITIATED) - created_at DateTime? @default(now()) @db.Timestamp(6) - updated_at DateTime? @default(now()) @db.Timestamp(6) -} - -model user_default_payment_method { - user_id String @id @db.VarChar(80) - payment_method_id Int - payment_method payment_method @relation(fields: [payment_method_id], references: [payment_method_id], onDelete: Cascade, onUpdate: NoAction, map: "fk_default_payment_method") -} - model user_payment_methods { id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid user_id String @db.VarChar(80) @@ -212,6 +196,14 @@ model trolley_webhook_log { created_at DateTime? @default(now()) @db.Timestamp(6) } +model user_identity_verification_associations { + id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + user_id String @db.VarChar(80) + verification_id String @db.Text + date_filed DateTime @db.Timestamp(6) + verification_status verification_status +} + model trolley_recipient_payment_method { id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid trolley_recipient_id Int @@ -219,6 +211,11 @@ 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") } +enum verification_status { + ACTIVE + INACTIVE +} + enum action_type { INITIATE_WITHDRAWAL ADD_WITHDRAWAL_METHOD diff --git a/src/api/admin/admin.controller.ts b/src/api/admin/admin.controller.ts index 284a63e..0b95cb0 100644 --- a/src/api/admin/admin.controller.ts +++ b/src/api/admin/admin.controller.ts @@ -164,19 +164,24 @@ export class AdminController { @Body() body: WinningUpdateRequestDto, @User() user: UserInfo, ): Promise> { - if (!body.paymentAmount && !body.releaseDate && !body.paymentStatus) { + if ( + !body.paymentAmount && + !body.releaseDate && + !body.paymentStatus && + !body.description + ) { throw new BadRequestException( - 'paymentStatus, releaseDate and paymentAmount cannot be null at the same time.', + 'description, paymentStatus, releaseDate and paymentAmount cannot be null at the same time.', ); } const result = await this.adminService.updateWinnings(body, user.id); + + result.status = ResponseStatusType.SUCCESS; if (result.error) { result.status = ResponseStatusType.ERROR; } - result.status = ResponseStatusType.SUCCESS; - return result; } @@ -200,12 +205,12 @@ export class AdminController { @Param('winningID') winningId: string, ): Promise> { const result = await this.adminService.getWinningAudit(winningId); + + result.status = ResponseStatusType.SUCCESS; if (result.error) { result.status = ResponseStatusType.ERROR; } - result.status = ResponseStatusType.SUCCESS; - return result; } @@ -230,12 +235,12 @@ export class AdminController { @Param('winningID') winningId: string, ): Promise> { const result = await this.adminService.getWinningAuditPayout(winningId); + + result.status = ResponseStatusType.SUCCESS; if (result.error) { result.status = ResponseStatusType.ERROR; } - result.status = ResponseStatusType.SUCCESS; - return result; } } diff --git a/src/api/admin/admin.module.ts b/src/api/admin/admin.module.ts index 7ceb127..2439f5c 100644 --- a/src/api/admin/admin.module.ts +++ b/src/api/admin/admin.module.ts @@ -1,8 +1,6 @@ import { Module } from '@nestjs/common'; import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; -import { TaxFormRepository } from '../repository/taxForm.repo'; -import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; import { WinningsRepository } from '../repository/winnings.repo'; import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; import { PaymentsModule } from 'src/shared/payments'; @@ -10,11 +8,6 @@ import { PaymentsModule } from 'src/shared/payments'; @Module({ imports: [TopcoderModule, PaymentsModule], controllers: [AdminController], - providers: [ - AdminService, - TaxFormRepository, - PaymentMethodRepository, - WinningsRepository, - ], + providers: [AdminService, WinningsRepository], }) export class AdminModule {} diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index d1e6d18..56b36af 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -3,19 +3,21 @@ import { HttpStatus, NotFoundException, BadRequestException, - Logger, } from '@nestjs/common'; -import { PrismaPromise } from '@prisma/client'; +import { Prisma } from '@prisma/client'; import { PrismaService } from 'src/shared/global/prisma.service'; import { PaymentsService } from 'src/shared/payments'; -import { TaxFormRepository } from '../repository/taxForm.repo'; -import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; 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 { Logger } from 'src/shared/global'; /** * The admin winning service. @@ -30,11 +32,14 @@ export class AdminService { */ constructor( private readonly prisma: PrismaService, - private readonly taxFormRepo: TaxFormRepository, - private readonly paymentMethodRepo: PaymentMethodRepository, private readonly paymentsService: PaymentsService, + private readonly tcChallengesService: TopcoderChallengesService, ) {} + private getWinningById(winningId: string) { + return this.prisma.winnings.findFirst({ where: { winning_id: winningId } }); + } + private getPaymentsByWinningsId(winningsId: string, paymentId?: string) { return this.prisma.payment.findMany({ where: { @@ -47,6 +52,9 @@ export class AdminService { } : undefined, }, + include: { + winnings: true, + }, }); } @@ -79,7 +87,9 @@ export class AdminService { releaseDate = await this.getPaymentReleaseDateByWinningsId(winningsId); } - const transactions: PrismaPromise[] = []; + const transactions: (( + tx: Prisma.TransactionClient, + ) => Promise)[] = []; const now = new Date().getTime(); payments.forEach((payment) => { if ( @@ -90,6 +100,42 @@ export class AdminService { } let version = payment.version ?? 1; + + if (body.description) { + transactions.push((tx) => + tx.payment.update({ + where: { + payment_id: payment.payment_id, + version: version, + }, + data: { + winnings: { + update: { + data: { + description: body.description, + }, + }, + }, + updated_at: new Date(), + updated_by: userId, + version, + }, + }), + ); + + if (payment.installment_number === 1) { + transactions.push((tx) => + this.addAudit( + userId, + winningsId, + `Modified payment description from "${payment.winnings.description}" to "${body.description}"`, + body.auditNote, + tx, + ), + ); + } + } + let paymentStatus = payment.payment_status as PaymentStatus; // Update Payment Status if requested if (body.paymentStatus) { @@ -108,8 +154,8 @@ export class AdminService { if (sinceRelease < 12) { errMessage = `Cannot put a processing payment back to owed, unless it's been processing for at least 12 hours. Currently it's only been ${sinceRelease.toFixed(1)} hours`; } else { - transactions.push( - this.markPaymentReleaseAsFailedByWinningsId(winningsId), + transactions.push((tx) => + this.markPaymentReleaseAsFailedByWinningsId(winningsId, tx), ); } } else { @@ -137,17 +183,18 @@ export class AdminService { throw new BadRequestException(errMessage); } - transactions.push( + transactions.push((tx) => this.updatePaymentStatus( userId, winningsId, payment.payment_id, payment.payment_status, body.paymentStatus, - version, + version++, + tx, ), ); - version += 1; + paymentStatus = body.paymentStatus as PaymentStatus; if (body.paymentStatus === PaymentStatus.OWED) { @@ -155,12 +202,13 @@ export class AdminService { } if (payment.installment_number === 1) { - transactions.push( + transactions.push((tx) => this.addAudit( userId, winningsId, `Modified payment status from ${payment.payment_status} to ${body.paymentStatus}`, body.auditNote, + tx, ), ); } @@ -186,24 +234,25 @@ export class AdminService { ); } - transactions.push( + transactions.push((tx) => this.updateReleaseDate( userId, winningsId, payment.payment_id, newReleaseDate, - version, + version++, + tx, ), ); - version += 1; if (payment.installment_number === 1) { - transactions.push( + transactions.push((tx) => this.addAudit( userId, winningsId, `Modified release date from ${payment.release_date?.toISOString()} to ${newReleaseDate.toISOString()}`, body.auditNote, + tx, ), ); } @@ -218,7 +267,7 @@ export class AdminService { ) { // ideally we should be maintaining the original split of the payment amount between installments - but we aren't really using splits anymore if (payment.installment_number === 1) { - transactions.push( + transactions.push((tx) => this.updatePaymentAmount( userId, winningsId, @@ -227,20 +276,22 @@ export class AdminService { body.paymentAmount, body.paymentAmount, version, + tx, ), ); - transactions.push( + transactions.push((tx) => this.addAudit( userId, winningsId, // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Modified payment amount from ${payment.total_amount} to ${body.paymentAmount.toFixed(2)}`, body.auditNote, + tx, ), ); } else { - transactions.push( + transactions.push((tx) => this.updatePaymentAmount( userId, winningsId, @@ -249,15 +300,43 @@ export class AdminService { 0, body.paymentAmount, version, + tx, ), ); } } }); - if (transactions.length > 0) { - await this.prisma.$transaction(transactions); - } + 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: 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) { + await transaction(tx); + } + }); if (needsReconciliation) { const winning = await this.prisma.winnings.findFirst({ @@ -320,8 +399,11 @@ export class AdminService { return paymentReleases?.release_date; } - private markPaymentReleaseAsFailedByWinningsId(winningsId: string) { - return this.prisma.payment_releases.updateMany({ + private markPaymentReleaseAsFailedByWinningsId( + winningsId: string, + tx?: Prisma.TransactionClient, + ) { + return (tx ?? this.prisma).payment_releases.updateMany({ where: { payment_release_associations: { some: { @@ -346,16 +428,22 @@ export class AdminService { oldPaymentStatus: string | null, newPaymentStatus: PaymentStatus, currentVersion: number, + tx?: Prisma.TransactionClient, ) { let setDatePaidNull = false; if ( - (oldPaymentStatus === PaymentStatus.PAID || - oldPaymentStatus === PaymentStatus.PROCESSING) && + [ + PaymentStatus.PAID, + PaymentStatus.PROCESSING, + PaymentStatus.RETURNED, + PaymentStatus.FAILED, + ].includes(oldPaymentStatus as PaymentStatus) && newPaymentStatus === PaymentStatus.OWED ) { setDatePaidNull = true; } - return this.prisma.payment.update({ + + return (tx ?? this.prisma).payment.update({ where: { payment_id: paymentId, winnings_id: winningsId, @@ -376,8 +464,9 @@ export class AdminService { winningsId: string, action: string, auditNote?: string, + tx?: Prisma.TransactionClient, ) { - return this.prisma.audit.create({ + return (tx ?? this.prisma).audit.create({ data: { user_id: userId, winnings_id: winningsId, @@ -393,8 +482,9 @@ export class AdminService { paymentId: string, newReleaseDate: Date, currentVersion: number, + tx?: Prisma.TransactionClient, ) { - return this.prisma.payment.update({ + return (tx ?? this.prisma).payment.update({ where: { payment_id: paymentId, winnings_id: winningsId, @@ -424,8 +514,9 @@ export class AdminService { grossAmount: number, totalAmount: number, currentVersion: number, + tx?: Prisma.TransactionClient, ) { - return this.prisma.payment.update({ + return (tx ?? this.prisma).payment.update({ where: { payment_id: paymentId, winnings_id: winningsId, @@ -458,17 +549,19 @@ export class AdminService { */ async getWinningAudit( winningId: string, + tx?: Prisma.TransactionClient, ): Promise> { const result = new ResponseDto(); try { - const audits = await this.prisma.audit.findMany({ + const audits = await (tx ?? this.prisma).audit.findMany({ where: { winnings_id: { equals: winningId, }, }, take: 1000, + orderBy: { created_at: 'desc' }, }); result.data = audits.map((item) => ({ @@ -498,11 +591,14 @@ export class AdminService { */ async getWinningAuditPayout( winningId: string, + tx?: Prisma.TransactionClient, ): Promise> { const result = new ResponseDto(); try { - const paymentReleases = await this.prisma.payment_releases.findMany({ + const paymentReleases = await ( + tx ?? this.prisma + ).payment_releases.findMany({ where: { payment_release_associations: { some: { diff --git a/src/api/admin/dto/winnings.dto.ts b/src/api/admin/dto/winnings.dto.ts index a768d8f..367851a 100644 --- a/src/api/admin/dto/winnings.dto.ts +++ b/src/api/admin/dto/winnings.dto.ts @@ -19,6 +19,14 @@ export class WinningUpdateRequestDto { @IsUUID() winningsId: string; + @ApiProperty({ + description: 'Payment description', + example: 'Task Payment', + }) + @IsOptional() + @IsString() + description: string; + @ApiProperty({ description: 'The audit note', example: 'audit note', diff --git a/src/api/api.module.ts b/src/api/api.module.ts index 3714dfe..2da3f93 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -3,6 +3,7 @@ import { HealthCheckController } from './health-check/healthCheck.controller'; import { GlobalProvidersModule } from 'src/shared/global/globalProviders.module'; import { APP_GUARD } from '@nestjs/core'; import { TokenValidatorMiddleware } from 'src/core/auth/middleware/tokenValidator.middleware'; +import { CreateRequestStoreMiddleware } from 'src/core/request/createRequestStore.middleware'; import { AuthGuard, RolesGuard } from 'src/core/auth/guards'; import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; import { OriginRepository } from './repository/origin.repo'; @@ -48,5 +49,6 @@ import { WithdrawalModule } from './withdrawal/withdrawal.module'; export class ApiModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(TokenValidatorMiddleware).forRoutes('*'); + consumer.apply(CreateRequestStoreMiddleware).forRoutes('*'); } } diff --git a/src/api/health-check/healthCheck.controller.ts b/src/api/health-check/healthCheck.controller.ts index 8c5f9bc..b9c7f06 100644 --- a/src/api/health-check/healthCheck.controller.ts +++ b/src/api/health-check/healthCheck.controller.ts @@ -1,13 +1,8 @@ -import { - Controller, - Get, - Logger, - Version, - VERSION_NEUTRAL, -} from '@nestjs/common'; +import { Controller, Get, Version, VERSION_NEUTRAL } from '@nestjs/common'; import { ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import { Public } from 'src/core/auth/decorators'; import { PrismaService } from 'src/shared/global/prisma.service'; +import { Logger } from 'src/shared/global'; export enum HealthCheckStatus { healthy = 'healthy', diff --git a/src/api/payment-providers/trolley.service.ts b/src/api/payment-providers/trolley.service.ts index 5fce55f..1992c23 100644 --- a/src/api/payment-providers/trolley.service.ts +++ b/src/api/payment-providers/trolley.service.ts @@ -1,5 +1,5 @@ -import { Injectable } from '@nestjs/common'; -import { UserInfo } from 'src/dto/user.dto'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { UserInfo } from 'src/dto/user.type'; import { TrolleyService as Trolley } from 'src/shared/global/trolley.service'; import { PrismaService } from 'src/shared/global/prisma.service'; import { BASIC_MEMBER_FIELDS } from 'src/shared/topcoder'; @@ -157,6 +157,12 @@ export class TrolleyService { * @returns A URL string to the Trolley user portal. */ async getPortalUrlForUser(user: UserInfo) { + if (user.email.toLowerCase().indexOf('@wipro.com') > -1) { + throw new BadRequestException( + 'Please contact Topgear support to withdrawal your payments', + ); + } + const recipient = await this.getPayeeRecipient(user); const link = this.trolley.getRecipientPortalUrl({ email: user.email, diff --git a/src/api/repository/identity-verification.repo.ts b/src/api/repository/identity-verification.repo.ts new file mode 100644 index 0000000..4c1fd92 --- /dev/null +++ b/src/api/repository/identity-verification.repo.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { verification_status } from '@prisma/client'; +import { PrismaService } from 'src/shared/global/prisma.service'; + +@Injectable() +export class IdentityVerificationRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * Checks if the user has completed their identity verification by checking the identity verification associations + * + * @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, + }, + }); + + return count > 0; + } +} diff --git a/src/api/repository/winnings.repo.ts b/src/api/repository/winnings.repo.ts index d236918..3f5c303 100644 --- a/src/api/repository/winnings.repo.ts +++ b/src/api/repository/winnings.repo.ts @@ -1,4 +1,4 @@ -import { HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { payment_status, Prisma, @@ -15,6 +15,7 @@ import { WinningsCategory, } from 'src/dto/winning.dto'; import { PrismaService } from 'src/shared/global/prisma.service'; +import { Logger } from 'src/shared/global'; const ONE_DAY = 24 * 60 * 60 * 1000; diff --git a/src/api/user/user.controller.ts b/src/api/user/user.controller.ts index f4fa123..2ef8376 100644 --- a/src/api/user/user.controller.ts +++ b/src/api/user/user.controller.ts @@ -23,12 +23,19 @@ import { ResponseDto, ResponseStatusType } from 'src/dto/api-response.dto'; import { SearchWinningResult, WinningRequestDto } from 'src/dto/winning.dto'; import { UserInfo } from 'src/dto/user.type'; import { UserWinningRequestDto } from './dto/user.dto'; +import { PaymentsService } from 'src/shared/payments'; +import { Logger } from 'src/shared/global'; @ApiTags('UserWinning') @Controller('/user') @ApiBearerAuth() export class UserController { - constructor(private readonly winningsRepo: WinningsRepository) {} + private readonly logger = new Logger(UserController.name); + + constructor( + private readonly winningsRepo: WinningsRepository, + private readonly paymentsService: PaymentsService, + ) {} @Post('/winnings') @Roles(Role.User) @@ -59,15 +66,29 @@ export class UserController { throw new ForbiddenException('insufficient permissions'); } + try { + await this.paymentsService.reconcileUserPayments(user.id); + } catch (e) { + this.logger.error('Error reconciling user payments', e); + + return { + error: { + code: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Failed to reconcile user payments.', + }, + status: ResponseStatusType.ERROR, + } as ResponseDto; + } + const result = await this.winningsRepo.searchWinnings( body as WinningRequestDto, ); + + result.status = ResponseStatusType.SUCCESS; if (result.error) { result.status = ResponseStatusType.ERROR; } - result.status = ResponseStatusType.SUCCESS; - return result; } } diff --git a/src/api/user/user.module.ts b/src/api/user/user.module.ts index 831da2f..4b3ecc5 100644 --- a/src/api/user/user.module.ts +++ b/src/api/user/user.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { UserController } from './user.controller'; import { WinningsRepository } from '../repository/winnings.repo'; +import { PaymentsModule } from 'src/shared/payments'; @Module({ - imports: [], + imports: [PaymentsModule], controllers: [UserController], providers: [WinningsRepository], }) diff --git a/src/api/wallet/wallet.controller.ts b/src/api/wallet/wallet.controller.ts index c17b9fd..34269ee 100644 --- a/src/api/wallet/wallet.controller.ts +++ b/src/api/wallet/wallet.controller.ts @@ -43,12 +43,12 @@ export class WalletController { @User() user: UserInfo, ): Promise> { const result = await this.walletService.getWalletDetails(user.id); + + result.status = ResponseStatusType.SUCCESS; if (result.error) { result.status = ResponseStatusType.ERROR; } - result.status = ResponseStatusType.SUCCESS; - return result; } } diff --git a/src/api/wallet/wallet.module.ts b/src/api/wallet/wallet.module.ts index 6e6aaf4..60f35b0 100644 --- a/src/api/wallet/wallet.module.ts +++ b/src/api/wallet/wallet.module.ts @@ -3,10 +3,16 @@ import { WalletController } from './wallet.controller'; import { WalletService } from './wallet.service'; import { TaxFormRepository } from '../repository/taxForm.repo'; import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; +import { IdentityVerificationRepository } from '../repository/identity-verification.repo'; @Module({ imports: [], controllers: [WalletController], - providers: [WalletService, TaxFormRepository, PaymentMethodRepository], + providers: [ + WalletService, + TaxFormRepository, + PaymentMethodRepository, + IdentityVerificationRepository, + ], }) export class WalletModule {} diff --git a/src/api/wallet/wallet.service.ts b/src/api/wallet/wallet.service.ts index 9077efa..3c3ab40 100644 --- a/src/api/wallet/wallet.service.ts +++ b/src/api/wallet/wallet.service.ts @@ -1,4 +1,4 @@ -import { Injectable, HttpStatus, Logger } from '@nestjs/common'; +import { Injectable, HttpStatus } from '@nestjs/common'; import { PrismaService } from 'src/shared/global/prisma.service'; @@ -7,7 +7,13 @@ import { ResponseDto } from 'src/dto/api-response.dto'; import { WinningsType } from 'src/dto/winning.dto'; import { TaxFormRepository } from '../repository/taxForm.repo'; import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; -import { TrolleyService } from 'src/shared/global/trolley.service'; +import { + RecipientTaxDetails, + TrolleyService, +} from 'src/shared/global/trolley.service'; +import { Logger } from 'src/shared/global'; +import { IdentityVerificationRepository } from '../repository/identity-verification.repo'; +import { ENV_CONFIG } from 'src/config'; /** * The winning service. @@ -24,10 +30,11 @@ export class WalletService { private readonly prisma: PrismaService, private readonly taxFormRepo: TaxFormRepository, private readonly paymentMethodRepo: PaymentMethodRepository, + private readonly identityVerificationRepo: IdentityVerificationRepository, private readonly trolleyService: TrolleyService, ) {} - async getPaymentTaxDetails(userId: string) { + async getPayoutDetails(userId: string) { const recipient = await this.prisma.trolley_recipient.findFirst({ where: { user_id: userId }, }); @@ -36,9 +43,7 @@ export class WalletService { return; } - return await this.trolleyService.getRecipientTaxDetails( - recipient.trolley_id, - ); + return this.trolleyService.getRecipientPayoutDetails(recipient.trolley_id); } /** @@ -55,11 +60,17 @@ export class WalletService { const winnings = await this.getWinningsTotalsByWinnerID(userId); const hasActiveTaxForm = await this.taxFormRepo.hasActiveTaxForm(userId); + const isIdentityVerified = + await this.identityVerificationRepo.completedIdentityVerification( + userId, + ); const hasVerifiedPaymentMethod = Boolean( await this.paymentMethodRepo.getConnectedPaymentMethod(userId), ); - const taxWithholdingDetails = await this.getPaymentTaxDetails(userId); + const payoutDetails = ((await this.getPayoutDetails(userId)) ?? + {}) as RecipientTaxDetails; + const { payoutMethod, ...taxWithholdingDetails } = payoutDetails; result.data = { account: { @@ -82,11 +93,16 @@ export class WalletService { }, withdrawalMethod: { isSetupComplete: hasVerifiedPaymentMethod, + type: { 'bank-transfer': 'bank' }[payoutMethod] ?? payoutMethod, }, taxForm: { isSetupComplete: hasActiveTaxForm, }, + identityVerification: { + isSetupComplete: isIdentityVerified, + }, ...(taxWithholdingDetails ?? {}), + minWithdrawAmount: ENV_CONFIG.TROLLEY_MINIMUM_PAYMENT_AMOUNT ?? 0, }; } catch (error) { this.logger.error('Getting winnings audit failed', error); diff --git a/src/api/webhooks/trolley/handlers/index.ts b/src/api/webhooks/trolley/handlers/index.ts index 0916b44..100aa16 100644 --- a/src/api/webhooks/trolley/handlers/index.ts +++ b/src/api/webhooks/trolley/handlers/index.ts @@ -3,6 +3,7 @@ import { PaymentHandler } from './payment.handler'; import { TaxFormHandler } from './tax-form.handler'; import { getWebhooksEventHandlersProvider } from '../../webhooks.event-handlers.provider'; import { RecipientAccountHandler } from './recipient-account.handler'; +import { RecipientVerificationHandler } from './recipient-verification.handler'; export const TrolleyWebhookHandlers: Provider[] = [ getWebhooksEventHandlersProvider( @@ -12,14 +13,26 @@ export const TrolleyWebhookHandlers: Provider[] = [ PaymentHandler, RecipientAccountHandler, + RecipientVerificationHandler, TaxFormHandler, { provide: 'TrolleyWebhookHandlers', - inject: [PaymentHandler, RecipientAccountHandler, TaxFormHandler], + inject: [ + PaymentHandler, + RecipientAccountHandler, + RecipientVerificationHandler, + TaxFormHandler, + ], useFactory: ( paymentHandler: PaymentHandler, recipientAccountHandler: RecipientAccountHandler, + recipientVerificationHandler: RecipientVerificationHandler, taxFormHandler: TaxFormHandler, - ) => [paymentHandler, recipientAccountHandler, taxFormHandler], + ) => [ + paymentHandler, + recipientAccountHandler, + recipientVerificationHandler, + taxFormHandler, + ], }, ]; diff --git a/src/api/webhooks/trolley/handlers/payment.handler.ts b/src/api/webhooks/trolley/handlers/payment.handler.ts index 521e9aa..e12a0a0 100644 --- a/src/api/webhooks/trolley/handlers/payment.handler.ts +++ b/src/api/webhooks/trolley/handlers/payment.handler.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { PaymentProcessedEventData, PaymentProcessedEventStatus, @@ -9,6 +9,7 @@ import { PaymentsService } from 'src/shared/payments'; import { payment_status } from '@prisma/client'; import { PrismaService } from 'src/shared/global/prisma.service'; import { JsonObject } from '@prisma/client/runtime/library'; +import { Logger } from 'src/shared/global'; @Injectable() export class PaymentHandler { @@ -34,7 +35,11 @@ export class PaymentHandler { paymentId, payload.status.toUpperCase() as payment_status, payload.status.toUpperCase(), - { failureMessage: payload.failureMessage }, + { + failureMessage: payload.failureMessage, + returnedNote: payload.returnedNote, + errors: payload.errors?.join(', '), + }, ); return; @@ -52,10 +57,11 @@ export class PaymentHandler { const winningIds = ( await this.prisma.$queryRaw<{ id: string }[]>` SELECT winnings_id as id - FROM public.payment p - INNER JOIN public.payment_release_associations pra + FROM payment p + INNER JOIN payment_release_associations pra ON pra.payment_id = p.payment_id WHERE pra.payment_release_id::text = ${paymentId} + FOR UPDATE ` ).map((w) => w.id); diff --git a/src/api/webhooks/trolley/handlers/payment.types.ts b/src/api/webhooks/trolley/handlers/payment.types.ts index 06eddef..9168bae 100644 --- a/src/api/webhooks/trolley/handlers/payment.types.ts +++ b/src/api/webhooks/trolley/handlers/payment.types.ts @@ -23,6 +23,8 @@ export interface PaymentProcessedEventData { fees: string; targetAmount: string; // net amount failureMessage: string | null; + errors?: string[]; + returnedNote?: string; memo: string | null; batch: { id: string; diff --git a/src/api/webhooks/trolley/handlers/recipient-account.handler.ts b/src/api/webhooks/trolley/handlers/recipient-account.handler.ts index c829af5..930bfb2 100644 --- a/src/api/webhooks/trolley/handlers/recipient-account.handler.ts +++ b/src/api/webhooks/trolley/handlers/recipient-account.handler.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { WebhookEvent } from '../../webhooks.decorators'; import { PrismaService } from 'src/shared/global/prisma.service'; import { @@ -8,6 +8,7 @@ import { } from './recipient-account.types'; import { payment_method_status } from '@prisma/client'; import { PaymentsService } from 'src/shared/payments'; +import { Logger } from 'src/shared/global'; @Injectable() export class RecipientAccountHandler { diff --git a/src/api/webhooks/trolley/handlers/recipient-verification.handler.ts b/src/api/webhooks/trolley/handlers/recipient-verification.handler.ts new file mode 100644 index 0000000..48742d1 --- /dev/null +++ b/src/api/webhooks/trolley/handlers/recipient-verification.handler.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { WebhookEvent } from '../../webhooks.decorators'; +import { PrismaService } from 'src/shared/global/prisma.service'; +import { PaymentsService } from 'src/shared/payments'; +import { Logger } from 'src/shared/global'; +import { + RecipientVerificationStatusUpdateEventData, + RecipientVerificationType, + RecipientVerificationWebhookEvent, +} from './recipient-verification.types'; +import { Prisma, verification_status } from '@prisma/client'; + +@Injectable() +export class RecipientVerificationHandler { + private readonly logger = new Logger(RecipientVerificationHandler.name); + + constructor( + private readonly prisma: PrismaService, + private readonly paymentsService: PaymentsService, + ) {} + + @WebhookEvent(RecipientVerificationWebhookEvent.statusUpdated) + async handleStatusUpdated( + payload: RecipientVerificationStatusUpdateEventData, + ): Promise { + const recipient = await this.prisma.trolley_recipient.findFirst({ + where: { trolley_id: payload.recipientId }, + }); + + if (payload.type !== RecipientVerificationType.individual) { + this.logger.log( + `Handling only individual status updates, ignoring phone verification for recipient ${payload.recipientId}. Verification type: ${payload.type}.`, + ); + return; + } + + if (!recipient) { + this.logger.error( + `Recipient with trolley_id ${payload.recipientId} not found.`, + ); + throw new Error( + `Recipient with trolley_id ${payload.recipientId} not found.`, + ); + } + + const userIDV = + await this.prisma.user_identity_verification_associations.findFirst({ + where: { + user_id: recipient.user_id, + }, + }); + + const verificationData: Prisma.user_identity_verification_associationsCreateInput = + { + user_id: recipient.user_id, + verification_id: payload.id, + date_filed: payload.submittedAt ?? new Date(), + verification_status: + payload.status === 'approved' + ? verification_status.ACTIVE + : verification_status.INACTIVE, + }; + + if (userIDV) { + await this.prisma.user_identity_verification_associations.update({ + where: { + id: userIDV.id, + }, + data: { ...verificationData }, + }); + } else { + await this.prisma.user_identity_verification_associations.create({ + data: { ...verificationData }, + }); + } + + await this.paymentsService.reconcileUserPayments(recipient.user_id); + } +} diff --git a/src/api/webhooks/trolley/handlers/recipient-verification.types.ts b/src/api/webhooks/trolley/handlers/recipient-verification.types.ts new file mode 100644 index 0000000..f53d21d --- /dev/null +++ b/src/api/webhooks/trolley/handlers/recipient-verification.types.ts @@ -0,0 +1,53 @@ +export enum RecipientVerificationWebhookEvent { + statusUpdated = 'recipientVerification.status_updated', +} + +export enum RecipientVerificationType { + phone = 'phone', + individual = 'individual', +} + +interface VerifiedIdentityData { + dob: string; + reason: string | null; + address: { + city: string; + region: string; + country: string; + street1: string; + street2: string; + postalCode: string; + }; + lastName: string; + firstName: string; + documentType: string; + matchSignals: { + yobMatch: boolean; + countryMatch: boolean; + postalCodeMatch: boolean | null; + }; + ageWhenVerified: number; + documentValidFrom: string | null; + documentValidUntil: string | null; + documentIssuingCountry: string; +} + +interface VerifiedPhoneData { + phone: string; + channel: 'sms' | 'call'; + country: string; + phoneExtension: string | null; +} + +export interface RecipientVerificationStatusUpdateEventData { + type: RecipientVerificationType; + recipientId: string; + status: 'pending' | 'approved' | 'rejected'; + createdAt: string; + updatedAt: string; + submittedAt: string | null; + decisionAt: string | null; + id: string; + reasonType: string | null; + verifiedData: VerifiedIdentityData | VerifiedPhoneData; +} diff --git a/src/api/webhooks/trolley/handlers/tax-form.handler.ts b/src/api/webhooks/trolley/handlers/tax-form.handler.ts index c5619c7..18ea555 100644 --- a/src/api/webhooks/trolley/handlers/tax-form.handler.ts +++ b/src/api/webhooks/trolley/handlers/tax-form.handler.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { WebhookEvent } from '../../webhooks.decorators'; import { PrismaService } from 'src/shared/global/prisma.service'; import { @@ -13,6 +13,7 @@ import { TaxFormStatusUpdatedEventData, TaxFormWebhookEvent, } from './tax-form.types'; +import { Logger } from 'src/shared/global'; @Injectable() export class TaxFormHandler { diff --git a/src/api/webhooks/trolley/trolley.service.ts b/src/api/webhooks/trolley/trolley.service.ts index 332964f..e71da58 100644 --- a/src/api/webhooks/trolley/trolley.service.ts +++ b/src/api/webhooks/trolley/trolley.service.ts @@ -1,8 +1,9 @@ import crypto from 'crypto'; -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { trolley_webhook_log, webhook_status } from '@prisma/client'; import { PrismaService } from 'src/shared/global/prisma.service'; import { ENV_CONFIG } from 'src/config'; +import { Logger } from 'src/shared/global'; export enum TrolleyHeaders { id = 'x-paymentrails-delivery', @@ -141,7 +142,7 @@ export class TrolleyService { this.logger.debug( `Invoking handler for event - ${requestId} - ${model}.${action}`, ); - await handler(body[model]); + await handler(body[model] ?? Object.values(body)?.[0]); this.logger.debug(`Successfully processed event with ID: ${requestId}`); await this.setEventState(requestId, webhook_status.processed); diff --git a/src/api/webhooks/webhooks.controller.ts b/src/api/webhooks/webhooks.controller.ts index 95e2bbd..8698f4f 100644 --- a/src/api/webhooks/webhooks.controller.ts +++ b/src/api/webhooks/webhooks.controller.ts @@ -4,11 +4,11 @@ import { Req, RawBodyRequest, ForbiddenException, - Logger, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TrolleyHeaders, TrolleyService } from './trolley/trolley.service'; import { Public } from 'src/core/auth/decorators'; +import { Logger } from 'src/shared/global'; @Public() @ApiTags('Webhooks') diff --git a/src/api/winnings/winnings.controller.ts b/src/api/winnings/winnings.controller.ts index 15a373b..81a64ff 100644 --- a/src/api/winnings/winnings.controller.ts +++ b/src/api/winnings/winnings.controller.ts @@ -53,12 +53,12 @@ export class WinningsController { body, user.id, ); + + result.status = ResponseStatusType.SUCCESS; if (result.error) { result.status = ResponseStatusType.ERROR; } - result.status = ResponseStatusType.SUCCESS; - return result; } @@ -84,9 +84,10 @@ export class WinningsController { ): Promise> { const result = await this.winningsRepo.searchWinnings(body); - result.status = result.error - ? ResponseStatusType.ERROR - : ResponseStatusType.SUCCESS; + result.status = ResponseStatusType.SUCCESS; + if (result.error) { + result.status = ResponseStatusType.ERROR; + } return { ...result, diff --git a/src/api/winnings/winnings.module.ts b/src/api/winnings/winnings.module.ts index 02c9ae5..c463ca0 100644 --- a/src/api/winnings/winnings.module.ts +++ b/src/api/winnings/winnings.module.ts @@ -5,9 +5,11 @@ import { OriginRepository } from '../repository/origin.repo'; import { WinningsRepository } from '../repository/winnings.repo'; import { TaxFormRepository } from '../repository/taxForm.repo'; import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; +import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; +import { IdentityVerificationRepository } from '../repository/identity-verification.repo'; @Module({ - imports: [], + imports: [TopcoderModule], controllers: [WinningsController], providers: [ WinningsService, @@ -15,6 +17,7 @@ import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; TaxFormRepository, WinningsRepository, PaymentMethodRepository, + IdentityVerificationRepository, ], }) export class WinningsModule {} diff --git a/src/api/winnings/winnings.service.ts b/src/api/winnings/winnings.service.ts index e1fd8c9..903fd96 100644 --- a/src/api/winnings/winnings.service.ts +++ b/src/api/winnings/winnings.service.ts @@ -1,5 +1,10 @@ -import { Injectable, HttpStatus, Logger } from '@nestjs/common'; -import { Prisma, payment, payment_status } from '@prisma/client'; +import { Injectable, HttpStatus } from '@nestjs/common'; +import { + Prisma, + payment, + payment_method_status, + payment_status, +} from '@prisma/client'; import { PrismaService } from 'src/shared/global/prisma.service'; @@ -9,6 +14,12 @@ import { PaymentStatus } from 'src/dto/payment.dto'; import { OriginRepository } from '../repository/origin.repo'; import { TaxFormRepository } from '../repository/taxForm.repo'; import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; +import { TopcoderMembersService } from 'src/shared/topcoder/members.service'; +import { BASIC_MEMBER_FIELDS } from 'src/shared/topcoder'; +import { ENV_CONFIG } from 'src/config'; +import { Logger } from 'src/shared/global'; +import { TopcoderEmailService } from 'src/shared/topcoder/tc-email.service'; +import { IdentityVerificationRepository } from '../repository/identity-verification.repo'; /** * The winning service. @@ -26,8 +37,98 @@ export class WinningsService { private readonly taxFormRepo: TaxFormRepository, private readonly paymentMethodRepo: PaymentMethodRepository, private readonly originRepo: OriginRepository, + private readonly tcMembersService: TopcoderMembersService, + private readonly identityVerificationRepo: IdentityVerificationRepository, + private readonly tcEmailService: TopcoderEmailService, ) {} + private async sendSetupEmailNotification(userId: string, amount: number) { + this.logger.debug(`Fetching member info for user handle: ${userId}`); + const member = await this.tcMembersService.getMemberInfoByUserId(userId, { + fields: BASIC_MEMBER_FIELDS, + }); + + if (!member) { + this.logger.warn( + `No member information found for user handle: ${userId}`, + ); + return; + } + + this.logger.debug( + `Member info retrieved successfully for user handle: ${userId}`, + { member }, + ); + + this.logger.debug( + `Preparing to send payment setup reminder email to: ${member.email}`, + ); + + try { + await this.tcEmailService.sendEmail( + member.email, + ENV_CONFIG.SENDGRID_TEMPLATE_ID_PAYMENT_SETUP_NOTIFICATION, + { + data: { + user_name: member.firstName || member.handle || member.lastName, + amount_won: amount, + wallet_link: `${ENV_CONFIG.TOPCODER_WALLET_URL}#payout`, + }, + }, + ); + + this.logger.debug( + `Payment setup reminder email sent successfully to: ${member.email}`, + ); + } catch (error) { + this.logger.error( + `Failed to send payment setup reminder email to: ${member.email}. Error: ${error.message}`, + error, + ); + } + } + + private async setPayrollPaymentMethod(userId: string) { + const payrollPaymentMethod = await this.prisma.payment_method.findFirst({ + where: { + payment_method_type: 'Wipro Payroll', + }, + }); + + if (!payrollPaymentMethod) { + this.logger.error(`Failed to retrieve Wipro Payroll payment method!`); + return; + } + + if ( + await this.prisma.user_payment_methods.findFirst({ + where: { + user_id: userId, + payment_method_id: payrollPaymentMethod.payment_method_id, + }, + }) + ) { + return; + } + + this.logger.debug(`Enrolling wipro user ${userId} with Wipro Payroll.`); + + try { + await this.prisma.user_payment_methods.create({ + data: { + user_id: userId, + status: payment_method_status.CONNECTED, + payment_method_id: payrollPaymentMethod.payment_method_id, + }, + }); + } catch (error) { + this.logger.error( + `Failed to enroll wipro user ${userId} with Wipro Payrol! ${error.message}`, + error, + ); + } + } + /** * Create winnings with parameters * @param body the request body @@ -40,6 +141,11 @@ export class WinningsService { ): Promise> { const result = new ResponseDto(); + this.logger.debug( + `Creating winning with payments for user ${userId}`, + body, + ); + return this.prisma.$transaction(async (tx) => { const originId = await this.originRepo.getOriginIdByName(body.origin, tx); @@ -68,6 +174,8 @@ export class WinningsService { }, }; + this.logger.debug('Constructed winning model', { winningModel }); + const payrollPayment = (body.attributes || {})['payroll'] === true; const hasActiveTaxForm = await this.taxFormRepo.hasActiveTaxForm( @@ -76,6 +184,10 @@ export class WinningsService { const hasConnectedPaymentMethod = Boolean( await this.paymentMethodRepo.getConnectedPaymentMethod(body.winnerId), ); + const isIdentityVerified = + await this.identityVerificationRepo.completedIdentityVerification( + userId, + ); for (const detail of body.details || []) { const paymentModel = { @@ -87,31 +199,61 @@ export class WinningsService { payment_status: '' as payment_status, created_by: userId, billing_account: detail.billingAccount, + challenge_fee: Prisma.Decimal(detail.challengeFee), }; paymentModel.net_amount = Prisma.Decimal(detail.grossAmount); paymentModel.payment_status = - hasConnectedPaymentMethod && hasActiveTaxForm + hasConnectedPaymentMethod && hasActiveTaxForm && isIdentityVerified ? PaymentStatus.OWED : PaymentStatus.ON_HOLD; if (payrollPayment) { + this.logger.debug( + `Payroll payment detected. Setting payment status to PAID for user ${body.winnerId}`, + ); paymentModel.payment_status = PaymentStatus.PAID; + await this.setPayrollPaymentMethod(body.winnerId); } winningModel.payment.create.push(paymentModel); + this.logger.debug('Added payment model to winning model', { + paymentModel, + }); } - // use prisma nested writes to avoid foreign key checks + + this.logger.debug('Attempting to create winning with nested payments.'); const createdWinning = await this.prisma.winnings.create({ data: winningModel as any, }); + if (!createdWinning) { + this.logger.error('Failed to create winning!'); result.error = { code: HttpStatus.INTERNAL_SERVER_ERROR, message: 'Failed to create winning!', }; + } else { + this.logger.debug('Successfully created winning', { createdWinning }); + } + + if ( + !payrollPayment && + (!hasConnectedPaymentMethod || !hasActiveTaxForm) + ) { + const amount = body.details.find( + (d) => d.installmentNumber === 1, + )?.totalAmount; + + if (amount) { + this.logger.debug( + `Sending setup email notification for user ${body.winnerId} with amount ${amount}`, + ); + void this.sendSetupEmailNotification(body.winnerId, amount); + } } + this.logger.debug('Transaction completed successfully.'); return result; }); } diff --git a/src/api/withdrawal/dto/withdraw.dto.ts b/src/api/withdrawal/dto/withdraw.dto.ts index 4099c85..ca5e57c 100644 --- a/src/api/withdrawal/dto/withdraw.dto.ts +++ b/src/api/withdrawal/dto/withdraw.dto.ts @@ -6,6 +6,7 @@ import { IsOptional, IsString, IsUUID, + Matches, MaxLength, } from 'class-validator'; import { ENV_CONFIG } from 'src/config'; @@ -20,6 +21,15 @@ export class WithdrawRequestDtoBase { @IsUUID('4', { each: true }) @IsNotEmpty({ each: true }) winningsIds: string[]; + + @ApiProperty({ + description: 'The one-time password (OTP) code for withdrawal verification', + example: '123456', + }) + @Matches(/^[0-9]{6}$/) + @IsOptional() + @IsNotEmpty() + otpCode?: string; } export class WithdrawRequestDtoWithMemo extends WithdrawRequestDtoBase { diff --git a/src/api/withdrawal/withdrawal.controller.ts b/src/api/withdrawal/withdrawal.controller.ts index b6759be..42be030 100644 --- a/src/api/withdrawal/withdrawal.controller.ts +++ b/src/api/withdrawal/withdrawal.controller.ts @@ -52,13 +52,17 @@ export class WithdrawalController { const result = new ResponseDto(); try { - await this.withdrawalService.withdraw( + const response = (await this.withdrawalService.withdraw( user.id, user.handle, body.winningsIds, body.memo, - ); - result.status = ResponseStatusType.SUCCESS; + body.otpCode, + )) as any; + result.status = response?.error + ? ResponseStatusType.ERROR + : ResponseStatusType.SUCCESS; + result.error = response?.error; return result; } catch (e) { throw new BadRequestException(e.message); diff --git a/src/api/withdrawal/withdrawal.module.ts b/src/api/withdrawal/withdrawal.module.ts index 3821d3c..d7150d0 100644 --- a/src/api/withdrawal/withdrawal.module.ts +++ b/src/api/withdrawal/withdrawal.module.ts @@ -4,10 +4,17 @@ import { WithdrawalController } from './withdrawal.controller'; import { WithdrawalService } from './withdrawal.service'; import { TaxFormRepository } from '../repository/taxForm.repo'; import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; +import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; +import { IdentityVerificationRepository } from '../repository/identity-verification.repo'; @Module({ - imports: [PaymentsModule], + imports: [PaymentsModule, TopcoderModule], controllers: [WithdrawalController], - providers: [WithdrawalService, TaxFormRepository, PaymentMethodRepository], + providers: [ + WithdrawalService, + TaxFormRepository, + PaymentMethodRepository, + IdentityVerificationRepository, + ], }) export class WithdrawalModule {} diff --git a/src/api/withdrawal/withdrawal.service.ts b/src/api/withdrawal/withdrawal.service.ts index 4117969..10a9179 100644 --- a/src/api/withdrawal/withdrawal.service.ts +++ b/src/api/withdrawal/withdrawal.service.ts @@ -1,11 +1,25 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ENV_CONFIG } from 'src/config'; import { PrismaService } from 'src/shared/global/prisma.service'; import { TaxFormRepository } from '../repository/taxForm.repo'; import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; -import { payment_releases, payment_status, Prisma } from '@prisma/client'; +import { IdentityVerificationRepository } from '../repository/identity-verification.repo'; +import { + payment_releases, + payment_status, + Prisma, + reference_type, +} 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 { TopcoderMembersService } from 'src/shared/topcoder/members.service'; +import { BasicMemberInfo, BASIC_MEMBER_FIELDS } from 'src/shared/topcoder'; +import { Logger } from 'src/shared/global'; +import { OtpService } from 'src/shared/global/otp.service'; const TROLLEY_MINIMUM_PAYMENT_AMOUNT = ENV_CONFIG.TROLLEY_MINIMUM_PAYMENT_AMOUNT; @@ -21,6 +35,16 @@ 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); @@ -30,10 +54,14 @@ export class WithdrawalService { private readonly taxFormRepo: TaxFormRepository, private readonly paymentsService: PaymentsService, private readonly paymentMethodRepo: PaymentMethodRepository, + private readonly identityVerificationRepo: IdentityVerificationRepository, private readonly trolleyService: TrolleyService, + private readonly tcChallengesService: TopcoderChallengesService, + private readonly tcMembersService: TopcoderMembersService, + private readonly otpService: OtpService, ) {} - getTrolleyRecipientByUserId(userId: string) { + getDbTrolleyRecipientByUserId(userId: string) { return this.prisma.trolley_recipient.findUnique({ where: { user_id: userId }, }); @@ -99,6 +127,7 @@ export class WithdrawalService { paymentMethodId: number, recipientId: string, winnings: ReleasableWinningRow[], + metadata: any, ) { try { const paymentRelease = await tx.payment_releases.create({ @@ -108,6 +137,7 @@ export class WithdrawalService { status: payment_status.PROCESSING, payment_method_id: paymentMethodId, payee_id: recipientId, + metadata, payment_release_associations: { createMany: { data: winnings.map((w) => ({ @@ -156,8 +186,12 @@ export class WithdrawalService { userHandle: string, winningsIds: string[], paymentMemo?: string, + otpCode?: string, ) { - this.logger.log('Processing withdrawal request'); + this.logger.log( + `Processing withdrawal request for user ${userHandle}(${userId}), winnings: ${winningsIds.join(', ')}`, + ); + const hasActiveTaxForm = await this.taxFormRepo.hasActiveTaxForm(userId); if (!hasActiveTaxForm) { @@ -175,6 +209,53 @@ export class WithdrawalService { ); } + const isIdentityVerified = + await this.identityVerificationRepo.completedIdentityVerification(userId); + + if (!isIdentityVerified) { + throw new Error( + 'Please complete identity verification before making a withdrawal.', + ); + } + + let userInfo: BasicMemberInfo; + this.logger.debug(`Getting user details for user ${userHandle}(${userId})`); + try { + userInfo = (await this.tcMembersService.getMemberInfoByUserHandle( + userHandle, + { fields: BASIC_MEMBER_FIELDS }, + )) as unknown as BasicMemberInfo; + } catch { + throw new Error('Failed to fetch UserInfo for withdrawal!'); + } + + if (!otpCode) { + const otpError = await this.otpService.generateOtpCode( + userInfo, + reference_type.WITHDRAW_PAYMENT, + ); + return { error: otpError }; + } else { + const otpResponse = await this.otpService.verifyOtpCode( + otpCode, + userInfo, + reference_type.WITHDRAW_PAYMENT, + ); + + if (!otpResponse || otpResponse.code !== 'success') { + return { error: otpResponse }; + } + } + + if (userInfo.email.toLowerCase().indexOf('wipro.com') > -1) { + this.logger.error( + `User ${userHandle}(${userId}) attempted withdrawal but is restricted due to email domain '${userInfo.email}'.`, + ); + throw new Error( + 'Please contact Topgear support to process your withdrawal.', + ); + } + try { await this.prisma.$transaction(async (tx) => { const winnings = await this.getReleasableWinningsForUserId( @@ -183,23 +264,73 @@ export class WithdrawalService { tx, ); + this.logger.log( + `Begin processing payments for user ${userHandle}(${userId})`, + winnings, + ); + + const dbTrolleyRecipient = + await this.getDbTrolleyRecipientByUserId(userId); + + if (!dbTrolleyRecipient) { + throw new Error( + `Trolley recipient not found for user ${userHandle}(${userId})!`, + ); + } + const totalAmount = this.checkTotalAmount(winnings); + let paymentAmount = totalAmount; + let feeAmount = 0; + const trolleyRecipientPayoutDetails = + await this.trolleyService.getRecipientPayoutDetails( + dbTrolleyRecipient.trolley_id, + ); + + if (!trolleyRecipientPayoutDetails) { + throw new Error( + `Recipient payout details not found for Trolley Recipient ID '${dbTrolleyRecipient.trolley_id}', for user ${userHandle}(${userId}).`, + ); + } - this.logger.log('Begin processing payments', winnings); + if ( + trolleyRecipientPayoutDetails.payoutMethod === 'paypal' && + ENV_CONFIG.TROLLEY_PAYPAL_FEE_PERCENT + ) { + const feePercent = + Number(ENV_CONFIG.TROLLEY_PAYPAL_FEE_PERCENT) / 100; - const recipient = await this.getTrolleyRecipientByUserId(userId); + feeAmount = +Math.min( + ENV_CONFIG.TROLLEY_PAYPAL_FEE_MAX_AMOUNT, + feePercent * paymentAmount, + ).toFixed(2); - if (!recipient) { - throw new Error(`Trolley recipient not found for user '${userId}'!`); + paymentAmount -= feeAmount; } + this.logger.log( + ` + Total amount won: $${totalAmount.toFixed(2)} USD, to be paid: $${paymentAmount.toFixed(2)} USD. + Payout method type: ${trolleyRecipientPayoutDetails.payoutMethod}. + `, + ); + const paymentRelease = await this.createDbPaymentRelease( tx, userId, - totalAmount, + paymentAmount, connectedPaymentMethod.payment_method_id, - recipient.trolley_id, + dbTrolleyRecipient.trolley_id, winnings, + { + netAmount: paymentAmount, + feeAmount, + totalAmount: totalAmount, + payoutMethod: trolleyRecipientPayoutDetails.payoutMethod, + env_trolley_paypal_fee_percent: + ENV_CONFIG.TROLLEY_PAYPAL_FEE_PERCENT, + env_trolley_paypal_fee_max_amount: + ENV_CONFIG.TROLLEY_PAYPAL_FEE_MAX_AMOUNT, + }, ); const paymentBatch = await this.trolleyService.startBatchPayment( @@ -207,9 +338,9 @@ export class WithdrawalService { ); const trolleyPayment = await this.trolleyService.createPayment( - recipient.trolley_id, + dbTrolleyRecipient.trolley_id, paymentBatch.id, - totalAmount, + paymentAmount, paymentRelease.payment_release_id, paymentMemo, ); @@ -236,6 +367,26 @@ export class WithdrawalService { this.logger.error(errorMsg, error); 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') { diff --git a/src/config/config.env.ts b/src/config/config.env.ts index f8c0194..fc47a24 100644 --- a/src/config/config.env.ts +++ b/src/config/config.env.ts @@ -1,5 +1,14 @@ import { Transform } from 'class-transformer'; -import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; export class ConfigEnv { @IsString() @@ -65,4 +74,40 @@ export class ConfigEnv { return false; }) ACCEPT_CUSTOM_PAYMENTS_MEMO; + + @IsString() + @IsOptional() + TC_EMAIL_NOTIFICATIONS_TOPIC = 'external.action.email'; + + @IsString() + @IsOptional() + TC_EMAIL_FROM_NAME = 'Topcoder'; + + @IsString() + @IsNotEmpty() + TC_EMAIL_FROM_EMAIL: string; + + @IsString() + SENDGRID_TEMPLATE_ID_PAYMENT_SETUP_NOTIFICATION = + 'd-919e01f1314e44439bc90971b55f7db7'; + + @IsString() + TOPCODER_WALLET_URL = 'https://wallet.topcoder.com'; + + @IsInt() + @Min(0) + @Max(99) + @IsOptional() + TROLLEY_PAYPAL_FEE_PERCENT: number = 0; + + @IsNumber() + @IsOptional() + TROLLEY_PAYPAL_FEE_MAX_AMOUNT: number = 0; + + @IsNumber() + @IsOptional() + OTP_CODE_VALIDITY_MINUTES: number = 5; + + @IsString() + SENDGRID_TEMPLATE_ID_OTP_CODE: string = 'd-2d0ab9f6c9cc4efba50080668a9c35c1'; } diff --git a/src/config/config.loader.ts b/src/config/config.loader.ts index 19c9c98..1660985 100644 --- a/src/config/config.loader.ts +++ b/src/config/config.loader.ts @@ -1,8 +1,10 @@ import * as dotenv from 'dotenv'; import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; -import { Logger } from '@nestjs/common'; import { ConfigEnv } from './config.env'; +import { Logger } from 'src/shared/global'; + +const logger = new Logger('ENV_CONFIG'); /** * Loads and validates environment variables into a `ConfigEnv` instance. @@ -31,13 +33,17 @@ function loadAndValidateEnv(): ConfigEnv { }); if (errors.length > 0) { - const logger = new Logger('Config'); for (const err of errors) { - logger.error(JSON.stringify(err.constraints)); + logger.error( + 'Invalid or missing environment variables!', + err.constraints, + ); } throw new Error('Invalid environment variables'); } + logger.debug(`Environment config vars successfully loaded and validated!`); + return env; } diff --git a/src/core/auth/jwt.ts b/src/core/auth/jwt.ts index a673697..55aacb2 100644 --- a/src/core/auth/jwt.ts +++ b/src/core/auth/jwt.ts @@ -1,7 +1,7 @@ -import { Logger } from '@nestjs/common'; import { decode } from 'jsonwebtoken'; import { JwksClient } from 'jwks-rsa'; import { ENV_CONFIG } from 'src/config'; +import { Logger } from 'src/shared/global'; const logger = new Logger(`auth/jwks`); @@ -26,7 +26,7 @@ const client = new JwksClient({ export const getSigningKey = (token: string) => { const tokenHeader = decode(token, { complete: true })?.header; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { if (!tokenHeader || !tokenHeader.kid) { logger.error('Invalid token: Missing key ID'); return reject(new Error('Invalid token: Missing key ID')); diff --git a/src/core/auth/middleware/tokenValidator.middleware.ts b/src/core/auth/middleware/tokenValidator.middleware.ts index ae75df0..5f3a562 100644 --- a/src/core/auth/middleware/tokenValidator.middleware.ts +++ b/src/core/auth/middleware/tokenValidator.middleware.ts @@ -1,12 +1,12 @@ import { Injectable, - Logger, NestMiddleware, UnauthorizedException, } from '@nestjs/common'; import * as jwt from 'jsonwebtoken'; import { ENV_CONFIG } from 'src/config'; import { getSigningKey } from '../jwt'; +import { Logger } from 'src/shared/global'; const logger = new Logger(`Auth/TokenValidatorMiddleware`); diff --git a/src/core/request/createRequestStore.middleware.ts b/src/core/request/createRequestStore.middleware.ts new file mode 100644 index 0000000..1b45180 --- /dev/null +++ b/src/core/request/createRequestStore.middleware.ts @@ -0,0 +1,14 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Response, NextFunction } from 'express'; +import { RequestMetadata, saveStore } from './requestStore'; + +@Injectable() +export class CreateRequestStoreMiddleware implements NestMiddleware { + constructor() {} + + use(req: any, res: Response, next: NextFunction) { + const requestMetaData = new RequestMetadata({}); + + saveStore(requestMetaData, next); + } +} diff --git a/src/core/request/requestStore.ts b/src/core/request/requestStore.ts new file mode 100644 index 0000000..3f77d07 --- /dev/null +++ b/src/core/request/requestStore.ts @@ -0,0 +1,39 @@ +import { AsyncLocalStorage } from 'async_hooks'; +import { NextFunction } from 'express'; +import { nanoid } from 'nanoid'; + +// Class for storing request specific metadata +export class RequestMetadata { + requestId: string; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-object-type + constructor(params: { requestId?: string }) { + this.requestId = params.requestId ?? nanoid(11); + } +} + +// Create a AsyncLocalStorage of type RequestMetaData for storing request specific data +const asyncStorage = new AsyncLocalStorage(); + +// Gets the RequestMetadada object associated with the current request +export function getStore(): RequestMetadata { + let store = asyncStorage.getStore(); + if (store === undefined) { + store = new RequestMetadata({ + requestId: '', + }); + } + + return store; +} + +// For use in middleware +// Saves RequestMetadata for the current request +export function saveStore( + requestMetaData: RequestMetadata, + next: NextFunction, +) { + asyncStorage.run(requestMetaData, () => { + next(); + }); +} diff --git a/src/dto/payment.dto.ts b/src/dto/payment.dto.ts index f358349..591f369 100644 --- a/src/dto/payment.dto.ts +++ b/src/dto/payment.dto.ts @@ -66,4 +66,12 @@ export class PaymentCreateRequestDto { @IsString() @IsNotEmpty() billingAccount: string; + + @ApiProperty({ + description: 'Challenge markup (fee) for the payment', + example: '0.5', + }) + @IsNumber() + @IsNotEmpty() + challengeFee: number; } diff --git a/src/dto/wallet.dto.ts b/src/dto/wallet.dto.ts index 7940bf6..02e1766 100644 --- a/src/dto/wallet.dto.ts +++ b/src/dto/wallet.dto.ts @@ -29,11 +29,16 @@ export class WalletDetailDto { }; withdrawalMethod: { isSetupComplete: boolean; + type: 'paypal' | 'bank'; }; taxForm: { isSetupComplete: boolean; }; + identityVerification: { + isSetupComplete: boolean; + }; primaryCurrency?: string | null; estimatedFees?: string | null; taxWithholdingPercentage?: string | null; + minWithdrawAmount: number; } diff --git a/src/main.ts b/src/main.ts index 605d6ca..08f9028 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import cors from 'cors'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; -import { Logger, ValidationPipe } from '@nestjs/common'; +import { ValidationPipe } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ApiModule } from './api/api.module'; import { AppModule } from './app.module'; @@ -13,6 +13,7 @@ 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'; async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -25,13 +26,37 @@ async function bootstrap() { app.setGlobalPrefix(ENV_CONFIG.API_BASE); // CORS related settings + const allowedOrigins: Array = [ + 'http://localhost:3000', + /\.localhost:3000$/, + ENV_CONFIG.TOPCODER_WALLET_URL, + /^https:\/\/[\w-]+\.topcoder-dev\.com$/, // allow wallet-v6 and other subdomains + ]; + const corsConfig: cors.CorsOptions = { allowedHeaders: 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Access-Control-Allow-Origin, Access-Control-Allow-Headers,currentOrg,overrideOrg,x-atlassian-cloud-id,x-api-key,x-orgid', credentials: true, - // origin: process.env.CORS_ALLOWED_ORIGIN - // ? new RegExp(process.env.CORS_ALLOWED_ORIGIN) - // : ['http://localhost:3000', /\.localhost:3000$/], + origin(origin, callback) { + if (!origin) { + callback(null, true); + return; + } + + const isAllowed = allowedOrigins.some((allowedOrigin) => + allowedOrigin instanceof RegExp + ? allowedOrigin.test(origin) + : allowedOrigin === origin, + ); + + if (isAllowed) { + callback(null, true); + return; + } + + logger.warn(`Blocked CORS origin: ${origin}`); + callback(new Error('Not allowed by CORS')); + }, methods: 'GET, POST, OPTIONS, PUT, DELETE, PATCH', }; app.use(cors(corsConfig)); diff --git a/src/shared/global/globalProviders.module.ts b/src/shared/global/globalProviders.module.ts index 3da605f..43bc680 100644 --- a/src/shared/global/globalProviders.module.ts +++ b/src/shared/global/globalProviders.module.ts @@ -1,12 +1,15 @@ import { Global, Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; import { TrolleyService } from './trolley.service'; +import { OtpService } from './otp.service'; +import { TopcoderModule } from '../topcoder/topcoder.module'; // Global module for providing global providers // Add any provider you want to be global here @Global() @Module({ - providers: [PrismaService, TrolleyService], - exports: [PrismaService, TrolleyService], + imports: [TopcoderModule], + providers: [PrismaService, TrolleyService, OtpService], + exports: [PrismaService, TrolleyService, OtpService], }) export class GlobalProvidersModule {} diff --git a/src/shared/global/index.ts b/src/shared/global/index.ts new file mode 100644 index 0000000..1ff09ef --- /dev/null +++ b/src/shared/global/index.ts @@ -0,0 +1 @@ +export * from './logger'; diff --git a/src/shared/global/logger.ts b/src/shared/global/logger.ts new file mode 100644 index 0000000..01577a9 --- /dev/null +++ b/src/shared/global/logger.ts @@ -0,0 +1,36 @@ +import { Logger as NestLogger } from '@nestjs/common'; +import stringify from 'json-stringify-safe'; +import { getStore } from 'src/core/request/requestStore'; + +export class Logger extends NestLogger { + private get store() { + return getStore(); + } + + log(...messages: any[]): void { + super.log(this.formatMessages(messages)); + } + + debug(...messages: any[]): void { + super.debug(this.formatMessages(messages)); + } + + info(...messages: any[]): void { + super.log(this.formatMessages(messages)); // NestJS doesn't have a dedicated `info` method, so we use `log`. + } + + error(...messages: any[]): void { + super.error(this.formatMessages(messages)); + } + + private formatMessages(messages: any[]): string { + const requestIdPrefix = this.store.requestId + ? [`{${this.store.requestId}}`] + : []; + return [...requestIdPrefix, ...messages] + .map((msg) => + typeof msg === 'object' ? stringify(msg, null, 2) : String(msg), + ) + .join(' '); + } +} diff --git a/src/shared/global/otp.service.ts b/src/shared/global/otp.service.ts new file mode 100644 index 0000000..e1256c0 --- /dev/null +++ b/src/shared/global/otp.service.ts @@ -0,0 +1,165 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import crypto from 'crypto'; +import { reference_type } from '@prisma/client'; +import { ENV_CONFIG } from 'src/config'; +import { TopcoderEmailService } from '../topcoder/tc-email.service'; +import { BasicMemberInfo } from '../topcoder'; + +const generateRandomOtp = (length: number): string => { + const digits = '0123456789'; + let otp = ''; + for (let i = 0; i < length; i++) { + otp += digits[Math.floor(Math.random() * digits.length)]; + } + return otp; +}; + +const hashOtp = (otp: string): string => { + const hasher = crypto.createHash('sha256'); + hasher.update(otp); + return hasher.digest('hex'); +}; + +@Injectable() +export class OtpService { + private readonly logger = new Logger(`global/OtpService`); + + constructor( + private readonly prisma: PrismaService, + private readonly tcEmailService: TopcoderEmailService, + ) {} + + async generateOtpCode(userInfo: BasicMemberInfo, actionType: reference_type) { + const email = userInfo.email; + + const existingOtp = await this.prisma.otp.findFirst({ + where: { + email, + action_type: actionType, + verified_at: null, + expiration_time: { + gt: new Date(), + }, + }, + orderBy: { + expiration_time: 'desc', + }, + }); + + if (existingOtp) { + this.logger.warn( + `An OTP has already been sent for email ${email} and action ${actionType}.`, + ); + return { + code: 'otp_exists', + message: 'An OTP has already been sent! Please check your email!', + }; + } + + // Generate a new OTP code + const otpCode = generateRandomOtp(6); // Generate a 6-digit OTP + const otpHash = hashOtp(otpCode); + + const expirationTime = new Date(); + expirationTime.setMinutes( + expirationTime.getMinutes() + ENV_CONFIG.OTP_CODE_VALIDITY_MINUTES, + ); + + // Save the new OTP code in the database + await this.prisma.otp.create({ + data: { + email, + action_type: actionType, + otp_hash: otpHash, + expiration_time: expirationTime, + created_at: new Date(), + }, + }); + + // Simulate sending an email (replace with actual email service logic) + await this.tcEmailService.sendEmail( + email, + ENV_CONFIG.SENDGRID_TEMPLATE_ID_OTP_CODE, + { + data: { + otp: otpCode, + name: [userInfo.firstName, userInfo.lastName] + .filter(Boolean) + .join(' '), + }, + }, + ); + this.logger.debug( + `Generated and sent OTP code ${otpCode.replace(/./g, '*')} for email ${email} and action ${actionType}.`, + ); + + return { + code: 'otp_required', + }; + } + + async verifyOtpCode( + otpCode: string, + userInfo: BasicMemberInfo, + actionType: reference_type, + ) { + const record = await this.prisma.otp.findFirst({ + where: { + otp_hash: hashOtp(otpCode), + }, + orderBy: { + expiration_time: 'desc', + }, + }); + + if (!record) { + this.logger.warn(`No OTP record found for the provided code.`); + return { code: 'otp_invalid', message: `Invalid OTP code.` }; + } + + if (record.email !== userInfo.email) { + this.logger.warn(`Email mismatch for OTP verification.`); + return { + code: 'otp_email_mismatch', + message: `Email mismatch for OTP verification.`, + }; + } + + if (record.action_type !== actionType) { + this.logger.warn(`Action type mismatch for OTP verification.`); + return { + code: 'otp_action_type_mismatch', + message: `Action type mismatch for OTP verification.`, + }; + } + + if (record.expiration_time && record.expiration_time < new Date()) { + this.logger.warn(`OTP code has expired.`); + return { code: 'otp_expired', message: `OTP code has expired.` }; + } + + if (record.verified_at !== null) { + this.logger.warn(`OTP code has already been verified.`); + return { + code: 'otp_already_verified', + message: `OTP code has already been verified.`, + }; + } + + this.logger.log( + `OTP code ${otpCode} verified successfully for action ${actionType}`, + ); + + await this.prisma.otp.update({ + where: { + id: record.id, + }, + data: { + verified_at: new Date(), + }, + }); + + return { code: 'success' }; + } +} diff --git a/src/shared/global/prisma.service.ts b/src/shared/global/prisma.service.ts index 2ada861..b6e741b 100644 --- a/src/shared/global/prisma.service.ts +++ b/src/shared/global/prisma.service.ts @@ -1,5 +1,6 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, OnModuleInit } from '@nestjs/common'; import { Prisma, PrismaClient } from '@prisma/client'; +import { Logger } from 'src/shared/global'; @Injectable() export class PrismaService diff --git a/src/shared/global/trolley.service.ts b/src/shared/global/trolley.service.ts index ccdfe78..cb6b235 100644 --- a/src/shared/global/trolley.service.ts +++ b/src/shared/global/trolley.service.ts @@ -2,8 +2,9 @@ import url from 'url'; import crypto from 'crypto'; import trolley from 'trolleyhq'; import { pick } from 'lodash'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ENV_CONFIG } from 'src/config'; +import { Logger } from 'src/shared/global'; const TROLLEY_ACCESS_KEY = ENV_CONFIG.TROLLEY_ACCESS_KEY; const TROLLEY_SECRET_KEY = ENV_CONFIG.TROLLEY_SECRET_KEY; @@ -13,6 +14,7 @@ export interface RecipientTaxDetails { primaryCurrency: string | null; estimatedFees: string | null; taxWithholdingPercentage: string | null; + payoutMethod: 'paypal' | 'bank-transfer'; } const client = trolley({ @@ -70,7 +72,7 @@ export class TrolleyService { hideEmail: 'false', roEmail: 'true', locale: 'en', - products: 'pay,tax', + products: 'pay,tax,trust', } as Record) .toString() .replace(/\+/g, '%20'); @@ -116,8 +118,8 @@ export class TrolleyService { recipient: { id: recipientId, }, - sourceAmount: totalAmount.toString(), - sourceCurrency: 'USD', + amount: totalAmount.toFixed(2), + currency: 'USD', memo: paymentMemo ?? 'Topcoder payment', externalId: transactionId, }; @@ -176,21 +178,30 @@ export class TrolleyService { } } - async getRecipientTaxDetails( + async getRecipientPayoutDetails( recipientId: string, ): Promise { try { const recipient = await this.client.recipient.find(recipientId); - return pick(recipient, [ + const payoutDetails = pick(recipient, [ 'estimatedFees', 'primaryCurrency', 'taxWithholdingPercentage', - ]) as RecipientTaxDetails; + 'payoutMethod', + ]); + + if ((recipient as any).payoutMethod === 'paypal') { + payoutDetails.estimatedFees = + (recipient as any).gatewayFees?.paypal?.value ?? 0; + } + + return payoutDetails as RecipientTaxDetails; } catch (error) { this.logger.error( - 'Failed to load recipient tax details from trolley!', + 'Failed to load recipient tax & payout details from trolley!', error, ); + return {} as RecipientTaxDetails; } } } diff --git a/src/shared/payments/payments.service.ts b/src/shared/payments/payments.service.ts index 9a2da5e..2bdc642 100644 --- a/src/shared/payments/payments.service.ts +++ b/src/shared/payments/payments.service.ts @@ -29,12 +29,19 @@ export class PaymentsService { setupComplete: boolean; }[] >` - SELECT - upm.user_id as "userId", - CASE WHEN utx.tax_form_status = 'ACTIVE' AND upm.status = 'CONNECTED' THEN TRUE ELSE FALSE END as "setupComplete" - FROM user_payment_methods upm - LEFT JOIN user_tax_form_associations utx ON upm.user_id = utx.user_id AND utx.tax_form_status = 'ACTIVE' - WHERE upm.user_id IN (${Prisma.join(uniq(userIds))}) + SELECT + upm.user_id as "userId", + CASE + WHEN utx.tax_form_status = 'ACTIVE' + AND upm.status = 'CONNECTED' + AND uiv.verification_status::text = 'ACTIVE' + THEN TRUE + ELSE FALSE + END as "setupComplete" + FROM user_payment_methods upm + LEFT JOIN user_tax_form_associations utx ON upm.user_id = utx.user_id AND utx.tax_form_status = 'ACTIVE' + LEFT JOIN user_identity_verification_associations uiv ON upm.user_id = uiv.user_id + WHERE upm.user_id IN (${Prisma.join(uniq(userIds))}) `; const setupStatusMap = { @@ -138,6 +145,21 @@ export class PaymentsService { metadata?: JsonObject, ) { const prismaClient = transaction || this.prisma; + + const failedOrReturnedRelease = + await prismaClient.payment_releases.findFirst({ + where: { + payment_release_id: paymentId, + status: { in: [payment_status.RETURNED, payment_status.FAILED] }, + }, + }); + + if (failedOrReturnedRelease) { + throw new Error( + `Not processing payment release ${paymentId} because it was already marked as '${failedOrReturnedRelease.status}'.`, + ); + } + try { const r = await prismaClient.payment_releases.updateMany({ where: { diff --git a/src/shared/topcoder/bus.service.ts b/src/shared/topcoder/bus.service.ts new file mode 100644 index 0000000..8397eec --- /dev/null +++ b/src/shared/topcoder/bus.service.ts @@ -0,0 +1,79 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { ENV_CONFIG } from 'src/config'; +import { TopcoderM2MService } from './topcoder-m2m.service'; +import { Logger } from '../global'; + +const { TOPCODER_API_BASE_URL } = ENV_CONFIG; + +@Injectable() +export class TopcoderBusService { + private readonly logger = new Logger(TopcoderBusService.name); + + constructor(private readonly m2MService: TopcoderM2MService) {} + + /** + * Get Http client headers for Bus API + * @return {Promise} Headers for Bus API + */ + private async getHeaders(): Promise { + try { + const token = await this.m2MService.getToken(); + const headers = new Headers(); + headers.append('Authorization', `Bearer ${token}`); + headers.append('Content-Type', 'application/json'); + return headers; + } catch (err) { + this.logger.error(`Error generating M2M token: ${err.message}`); + throw new Error(`Bus API - Error generating M2M token: ${err.message}`); + } + } + + /** + * Creates a new event in Bus API + * Any errors will be logged + * @param {string} topic The event topic, should be a dot-separated fully qualified name + * @param {object} payload The payload, should be a JSON object + * @return {Promise} + */ + async createEvent(topic: string, payload: any): Promise { + this.logger.debug(`Sending message to bus topic ${topic}`, { + ...payload, + data: {}, + }); + + try { + const headers = await this.getHeaders(); + const response = await fetch(`${TOPCODER_API_BASE_URL}/bus/events`, { + method: 'POST', + headers, + body: JSON.stringify({ + topic, + originator: 'tc-finance-api', + timestamp: new Date().toISOString(), + 'mime-type': 'application/json', + payload, + }), + }); + + if (!response.ok) { + this.logger.error(`Error sending event to bus-api for topic ${topic}`); + const errorData = await response.json(); + this.logger.error(`Response data: ${JSON.stringify(errorData)}`); + this.logger.error(`Response status: ${response.status}`); + } else { + this.logger.debug(`Sent event to bus-api for bus topic ${topic}`); + this.logger.debug(`Response status: ${response.status}`); + + if (response.status !== (HttpStatus.NO_CONTENT as number)) { + const responseData = await response.json(); + this.logger.debug(`Response data: ${JSON.stringify(responseData)}`); + } + } + } catch (error) { + this.logger.error( + `Error sending event to bus-api for topic ${topic}: ${error.message}`, + ); + throw error; + } + } +} diff --git a/src/shared/topcoder/challenges.service.ts b/src/shared/topcoder/challenges.service.ts new file mode 100644 index 0000000..fb087df --- /dev/null +++ b/src/shared/topcoder/challenges.service.ts @@ -0,0 +1,94 @@ +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; + datePaid: string; +} + +export interface AdminPaymentUpdateData { + userId: number; + status: string; + 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/member.types.ts b/src/shared/topcoder/member.types.ts index 9be6a89..eaa2a62 100644 --- a/src/shared/topcoder/member.types.ts +++ b/src/shared/topcoder/member.types.ts @@ -30,3 +30,13 @@ export const BASIC_MEMBER_FIELDS = [ MEMBER_FIELDS.addresses, MEMBER_FIELDS.homeCountryCode, ]; + +export interface BasicMemberInfo { + userId: string; + handle: string; + firstName: string; + lastName: string; + email: string; + addresses: any[]; + homeCountryCode: string; +} diff --git a/src/shared/topcoder/members.service.ts b/src/shared/topcoder/members.service.ts index 35acc68..c0bc57f 100644 --- a/src/shared/topcoder/members.service.ts +++ b/src/shared/topcoder/members.service.ts @@ -1,8 +1,9 @@ import { chunk } from 'lodash'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { MEMBER_FIELDS } from './member.types'; 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; @@ -79,17 +80,58 @@ export class TopcoderMembersService { const requestUrl = `${TOPCODER_API_BASE_URL}/members/${handle}${fields ? `?fields=${fields.join(',')}` : ''}`; try { - const response: { [key: string]: string } = await fetch(requestUrl, { + const response = await fetch(requestUrl, { headers: { Authorization: `Bearer ${m2mToken}` }, - }).then((r) => r.json()); - return response; + }); + + const jsonResponse: { [key: string]: string } = await response.json(); + + if (response.status > 299) { + throw new Error(jsonResponse.message ?? JSON.stringify(jsonResponse)); + } + + return jsonResponse; } catch (e) { this.logger.error( - `Failed to fetch tc member info for user '${handle}'! Error: `, - e?.message ?? e, + `Failed to fetch tc member info for user '${handle}'! Error: ${e?.message ?? e}`, e, ); - return {}; + throw e; + } + } + + /** + * Retrieves member information from the Topcoder API based on the user's ID. + * + * @param userId - The ID of the user whose information is to be retrieved. + * @param options - Optional parameters for the request. + * @param options.fields - An array of specific member fields to include in the response. + * + * @returns A promise that resolves to the member information object or an empty object if the request fails. + * + * @throws Will log an error message to the console if the API request fails. + */ + async getMemberInfoByUserId( + userId: string, + options = {} as { fields: MEMBER_FIELDS[] }, + ) { + try { + // Fetch the handle for the given userId + const handlesMap = await this.getHandlesByUserIds([userId]); + const handle = handlesMap[userId]; + + if (!handle) { + throw new Error(`Handle not found for userId: ${userId}`); + } + + // Fetch member info using the handle + return await this.getMemberInfoByUserHandle(handle, options); + } catch (e) { + this.logger.error( + `Failed to fetch tc member info for userId '${userId}'! Error: ${e?.message ?? e}`, + e, + ); + throw e; } } } diff --git a/src/shared/topcoder/tc-email.service.ts b/src/shared/topcoder/tc-email.service.ts new file mode 100644 index 0000000..92db3f1 --- /dev/null +++ b/src/shared/topcoder/tc-email.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ENV_CONFIG } from 'src/config'; +import { TopcoderBusService } from './bus.service'; + +export interface EmailEventPayload { + data: any; + from?: { + name: string; + email?: string; + }; +} + +@Injectable() +export class TopcoderEmailService { + private readonly logger = new Logger(TopcoderEmailService.name); + + constructor(private readonly tcBusService: TopcoderBusService) {} + + /** + * Sends an email using the specified SendGrid template and payload data. + * + * @param to - The recipient(s) of the email. Can be a single email address or an array of email addresses. + * @param sendgridTemplateId - The ID of the SendGrid email template to use. + * @param data - The payload data for the email, including dynamic template data and sender information. + * @returns A promise that resolves when the email is successfully sent, or rejects with an error if the operation fails. + * + * @throws Will throw an error if the email sending process fails. + */ + async sendEmail( + to: string[] | string, + sendgridTemplateId: string, + data: EmailEventPayload, + ): Promise { + const recipients = ([] as string[]).concat(to).flat(); + + try { + await this.tcBusService.createEvent( + ENV_CONFIG.TC_EMAIL_NOTIFICATIONS_TOPIC, + { + ...data, + recipients, + sendgrid_template_id: sendgridTemplateId, + version: 'v3', + from: { + name: data.from?.name ?? ENV_CONFIG.TC_EMAIL_FROM_NAME, + email: data.from?.email ?? ENV_CONFIG.TC_EMAIL_FROM_EMAIL, + }, + }, + ); + this.logger.debug(`Email sent to ${recipients.join(', ')} successfully!`); + } catch (error) { + this.logger.error(`Failed to send email to ${recipients.join()}:`, error); + throw error; + } + } +} diff --git a/src/shared/topcoder/topcoder-m2m.service.ts b/src/shared/topcoder/topcoder-m2m.service.ts index 6e9f691..06cc33a 100644 --- a/src/shared/topcoder/topcoder-m2m.service.ts +++ b/src/shared/topcoder/topcoder-m2m.service.ts @@ -1,5 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ENV_CONFIG } from 'src/config'; +import { Logger } from 'src/shared/global'; @Injectable() export class TopcoderM2MService { diff --git a/src/shared/topcoder/topcoder.module.ts b/src/shared/topcoder/topcoder.module.ts index bd90bb0..3071b22 100644 --- a/src/shared/topcoder/topcoder.module.ts +++ b/src/shared/topcoder/topcoder.module.ts @@ -1,9 +1,23 @@ import { Module } from '@nestjs/common'; import { TopcoderMembersService } from './members.service'; import { TopcoderM2MService } from './topcoder-m2m.service'; +import { TopcoderChallengesService } from './challenges.service'; +import { TopcoderBusService } from './bus.service'; +import { TopcoderEmailService } from './tc-email.service'; @Module({ - providers: [TopcoderMembersService, TopcoderM2MService], - exports: [TopcoderMembersService], + providers: [ + TopcoderChallengesService, + TopcoderMembersService, + TopcoderM2MService, + TopcoderBusService, + TopcoderEmailService, + ], + exports: [ + TopcoderChallengesService, + TopcoderMembersService, + TopcoderBusService, + TopcoderEmailService, + ], }) export class TopcoderModule {}