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/schema.prisma b/prisma/schema.prisma index f3b5d66..457b423 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -207,6 +207,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 @@ -214,6 +222,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/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/user/user.controller.ts b/src/api/user/user.controller.ts index c31991b..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,6 +66,20 @@ 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, ); 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.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 c19f70d..69f2250 100644 --- a/src/api/wallet/wallet.service.ts +++ b/src/api/wallet/wallet.service.ts @@ -12,6 +12,7 @@ import { TrolleyService, } from 'src/shared/global/trolley.service'; import { Logger } from 'src/shared/global'; +import { IdentityVerificationRepository } from '../repository/identity-verification.repo'; /** * The winning service. @@ -28,6 +29,7 @@ export class WalletService { private readonly prisma: PrismaService, private readonly taxFormRepo: TaxFormRepository, private readonly paymentMethodRepo: PaymentMethodRepository, + private readonly identityVerificationRepo: IdentityVerificationRepository, private readonly trolleyService: TrolleyService, ) {} @@ -57,6 +59,10 @@ 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), ); @@ -91,6 +97,9 @@ export class WalletService { taxForm: { isSetupComplete: hasActiveTaxForm, }, + identityVerification: { + isSetupComplete: isIdentityVerified, + }, ...(taxWithholdingDetails ?? {}), }; } catch (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/recipient-verification.handler.ts b/src/api/webhooks/trolley/handlers/recipient-verification.handler.ts new file mode 100644 index 0000000..5738da9 --- /dev/null +++ b/src/api/webhooks/trolley/handlers/recipient-verification.handler.ts @@ -0,0 +1,71 @@ +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, + 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 (!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..a84a8a5 --- /dev/null +++ b/src/api/webhooks/trolley/handlers/recipient-verification.types.ts @@ -0,0 +1,21 @@ +export enum RecipientVerificationWebhookEvent { + statusUpdated = 'recipientVerification.status_updated', +} + +export interface RecipientVerificationStatusUpdateEventData { + type: string; + recipientId: string; + status: 'pending' | 'approved' | 'rejected'; + createdAt: string; + updatedAt: string; + submittedAt: string | null; + decisionAt: string | null; + id: string; + reasonType: string | null; + verifiedData: { + channel: 'sms' | 'email'; + phone: string; + phoneExtension: string | null; + country: string; + }; +} diff --git a/src/api/webhooks/trolley/trolley.service.ts b/src/api/webhooks/trolley/trolley.service.ts index bf81bc2..e71da58 100644 --- a/src/api/webhooks/trolley/trolley.service.ts +++ b/src/api/webhooks/trolley/trolley.service.ts @@ -142,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/winnings/winnings.module.ts b/src/api/winnings/winnings.module.ts index cf00468..c463ca0 100644 --- a/src/api/winnings/winnings.module.ts +++ b/src/api/winnings/winnings.module.ts @@ -6,6 +6,7 @@ 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: [TopcoderModule], @@ -16,6 +17,7 @@ import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; TaxFormRepository, WinningsRepository, PaymentMethodRepository, + IdentityVerificationRepository, ], }) export class WinningsModule {} diff --git a/src/api/winnings/winnings.service.ts b/src/api/winnings/winnings.service.ts index 5aab1b2..903fd96 100644 --- a/src/api/winnings/winnings.service.ts +++ b/src/api/winnings/winnings.service.ts @@ -19,6 +19,7 @@ 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. @@ -37,6 +38,7 @@ export class WinningsService { private readonly paymentMethodRepo: PaymentMethodRepository, private readonly originRepo: OriginRepository, private readonly tcMembersService: TopcoderMembersService, + private readonly identityVerificationRepo: IdentityVerificationRepository, private readonly tcEmailService: TopcoderEmailService, ) {} @@ -182,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 = { @@ -198,7 +204,7 @@ export class WinningsService { paymentModel.net_amount = Prisma.Decimal(detail.grossAmount); paymentModel.payment_status = - hasConnectedPaymentMethod && hasActiveTaxForm + hasConnectedPaymentMethod && hasActiveTaxForm && isIdentityVerified ? PaymentStatus.OWED : PaymentStatus.ON_HOLD; diff --git a/src/api/withdrawal/withdrawal.module.ts b/src/api/withdrawal/withdrawal.module.ts index 075f4ce..d7150d0 100644 --- a/src/api/withdrawal/withdrawal.module.ts +++ b/src/api/withdrawal/withdrawal.module.ts @@ -5,10 +5,16 @@ 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, 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 c3a12d9..fc3fd71 100644 --- a/src/api/withdrawal/withdrawal.service.ts +++ b/src/api/withdrawal/withdrawal.service.ts @@ -3,6 +3,7 @@ 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 { IdentityVerificationRepository } from '../repository/identity-verification.repo'; import { payment_releases, payment_status, Prisma } from '@prisma/client'; import { TrolleyService } from 'src/shared/global/trolley.service'; import { PaymentsService } from 'src/shared/payments'; @@ -47,6 +48,7 @@ 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, @@ -198,6 +200,15 @@ 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: { email: string }; this.logger.debug(`Getting user details for user ${userHandle}(${userId})`); try { diff --git a/src/dto/wallet.dto.ts b/src/dto/wallet.dto.ts index dd26d69..129090d 100644 --- a/src/dto/wallet.dto.ts +++ b/src/dto/wallet.dto.ts @@ -34,6 +34,9 @@ export class WalletDetailDto { taxForm: { isSetupComplete: boolean; }; + identityVerification: { + isSetupComplete: boolean; + }; primaryCurrency?: string | null; estimatedFees?: string | null; taxWithholdingPercentage?: string | null; diff --git a/src/shared/payments/payments.service.ts b/src/shared/payments/payments.service.ts index b7aa4a4..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 = { @@ -139,12 +146,13 @@ export class PaymentsService { ) { 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] }, - } - }); + 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(