diff --git a/src/api/wallet/wallet.service.ts b/src/api/wallet/wallet.service.ts index 3c3ab40..7e4b19b 100644 --- a/src/api/wallet/wallet.service.ts +++ b/src/api/wallet/wallet.service.ts @@ -83,12 +83,6 @@ export class WalletService { ), unit: 'currency', }, - // hide rewards for now - // { - // type: WinningsType.REWARD, - // amount: rewardTotal, - // unit: 'points', - // }, ], }, withdrawalMethod: { @@ -118,7 +112,7 @@ export class WalletService { getWinningsTotalsByWinnerID(winnerId: string) { return this.prisma.$queryRaw< - { payment_type: 'PAYMENT' | 'REWARD'; total_owed: number }[] + { payment_type: 'PAYMENT'; total_owed: number }[] >` WITH latest_payment_version AS ( SELECT @@ -133,7 +127,6 @@ export class WalletService { w.type AS payment_type, CASE WHEN w.type = 'PAYMENT' THEN SUM(p.total_amount) - WHEN w.type = 'REWARD' THEN SUM(r.points) ELSE 0 END AS total_owed FROM @@ -144,8 +137,6 @@ export class WalletService { AND p.installment_number = 1 INNER JOIN latest_payment_version lpv ON p.winnings_id = lpv.winnings_id AND p.version = lpv.max_version - LEFT JOIN reward r ON w.winning_id = r.winnings_id - AND w.type = 'REWARD' WHERE w.winner_id = ${winnerId} GROUP BY diff --git a/src/api/withdrawal/withdrawal.service.ts b/src/api/withdrawal/withdrawal.service.ts index 10a9179..3add106 100644 --- a/src/api/withdrawal/withdrawal.service.ts +++ b/src/api/withdrawal/withdrawal.service.ts @@ -7,7 +7,6 @@ import { IdentityVerificationRepository } from '../repository/identity-verificat import { payment_releases, payment_status, - Prisma, reference_type, } from '@prisma/client'; import { TrolleyService } from 'src/shared/global/trolley.service'; @@ -70,9 +69,8 @@ export class WithdrawalService { private async getReleasableWinningsForUserId( userId: string, winningsIds: string[], - tx: Prisma.TransactionClient, ) { - const winnings = await tx.$queryRaw` + const winnings = await this.prisma.$queryRaw` SELECT p.payment_id as "paymentId", p.total_amount as amount, p.version, w.title, w.external_id as "externalId", p.payment_status as status, p.release_date as "releaseDate", p.date_paid as "datePaid" FROM payment p INNER JOIN winnings w on p.winnings_id = w.winning_id AND p.installment_number = 1 @@ -121,7 +119,6 @@ export class WithdrawalService { } private async createDbPaymentRelease( - tx: Prisma.TransactionClient, userId: string, totalAmount: number, paymentMethodId: number, @@ -130,7 +127,7 @@ export class WithdrawalService { metadata: any, ) { try { - const paymentRelease = await tx.payment_releases.create({ + const paymentRelease = await this.prisma.payment_releases.create({ data: { user_id: userId, total_net_amount: totalAmount, @@ -161,19 +158,23 @@ export class WithdrawalService { } private async updateDbReleaseRecord( - tx: Prisma.TransactionClient, paymentRelease: payment_releases, - externalTxId: string, + data: { externalTxId?: string; status?: string }, ) { try { - await tx.payment_releases.update({ + await this.prisma.payment_releases.update({ where: { payment_release_id: paymentRelease.payment_release_id }, - data: { external_transaction_id: externalTxId }, + data: { + external_transaction_id: data.externalTxId, + status: data.status, + }, }); - this.logger.log( - `DB payment_release[${paymentRelease.payment_release_id}] updated successfully with trolley payment id: ${externalTxId}`, - ); + if (data.externalTxId) { + this.logger.log( + `DB payment_release[${paymentRelease.payment_release_id}] updated successfully with trolley payment id: ${data.externalTxId}`, + ); + } } catch (error) { const errorMsg = `Failed to update DB payment_release: ${error.message}`; this.logger.error(errorMsg, error); @@ -229,6 +230,21 @@ export class WithdrawalService { throw new Error('Failed to fetch UserInfo for withdrawal!'); } + 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.', + ); + } + + // check winnings before even sending otp code + const winnings = await this.getReleasableWinningsForUserId( + userId, + winningsIds, + ); + if (!otpCode) { const otpError = await this.otpService.generateOtpCode( userInfo, @@ -236,158 +252,177 @@ export class WithdrawalService { ); return { error: otpError }; } else { - const otpResponse = await this.otpService.verifyOtpCode( - otpCode, - userInfo, - reference_type.WITHDRAW_PAYMENT, - ); + try { + const otpResponse = await this.otpService.verifyOtpCode( + otpCode, + userInfo, + reference_type.WITHDRAW_PAYMENT, + ); - if (!otpResponse || otpResponse.code !== 'success') { - return { error: otpResponse }; + if (!otpResponse || otpResponse.code !== 'success') { + return { error: otpResponse }; + } + } catch (error) { + if (error.code === 'P2010' && error.meta?.code === '55P03') { + this.logger.error( + 'Payment request denied because payment row was locked previously!', + error, + ); + + throw new Error( + 'Some or all of the winnings you requested to process are either processing, on hold or already paid.', + ); + } else { + throw error; + } } } - 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 { + this.logger.log( + `Begin processing payments for user ${userHandle}(${userId})`, + winnings, ); - } - try { - await this.prisma.$transaction(async (tx) => { - const winnings = await this.getReleasableWinningsForUserId( - userId, - winningsIds, - tx, + const dbTrolleyRecipient = + await this.getDbTrolleyRecipientByUserId(userId); + + if (!dbTrolleyRecipient) { + throw new Error( + `Trolley recipient not found for user ${userHandle}(${userId})!`, ); + } - this.logger.log( - `Begin processing payments for user ${userHandle}(${userId})`, - winnings, + const totalAmount = this.checkTotalAmount(winnings); + let paymentAmount = totalAmount; + let feeAmount = 0; + const trolleyRecipientPayoutDetails = + await this.trolleyService.getRecipientPayoutDetails( + dbTrolleyRecipient.trolley_id, ); - const dbTrolleyRecipient = - await this.getDbTrolleyRecipientByUserId(userId); + if (!trolleyRecipientPayoutDetails) { + throw new Error( + `Recipient payout details not found for Trolley Recipient ID '${dbTrolleyRecipient.trolley_id}', for user ${userHandle}(${userId}).`, + ); + } - if (!dbTrolleyRecipient) { - throw new Error( - `Trolley recipient not found for user ${userHandle}(${userId})!`, - ); - } + if ( + trolleyRecipientPayoutDetails.payoutMethod === 'paypal' && + ENV_CONFIG.TROLLEY_PAYPAL_FEE_PERCENT + ) { + const feePercent = Number(ENV_CONFIG.TROLLEY_PAYPAL_FEE_PERCENT) / 100; - const totalAmount = this.checkTotalAmount(winnings); - let paymentAmount = totalAmount; - let feeAmount = 0; - const trolleyRecipientPayoutDetails = - await this.trolleyService.getRecipientPayoutDetails( - dbTrolleyRecipient.trolley_id, - ); + feeAmount = +Math.min( + ENV_CONFIG.TROLLEY_PAYPAL_FEE_MAX_AMOUNT, + feePercent * paymentAmount, + ).toFixed(2); - if (!trolleyRecipientPayoutDetails) { - throw new Error( - `Recipient payout details not found for Trolley Recipient ID '${dbTrolleyRecipient.trolley_id}', for user ${userHandle}(${userId}).`, - ); - } + paymentAmount -= feeAmount; + } - if ( - trolleyRecipientPayoutDetails.payoutMethod === 'paypal' && - ENV_CONFIG.TROLLEY_PAYPAL_FEE_PERCENT - ) { - const feePercent = - Number(ENV_CONFIG.TROLLEY_PAYPAL_FEE_PERCENT) / 100; + this.logger.log( + ` + Total amount won: $${totalAmount.toFixed(2)} USD, to be paid: $${paymentAmount.toFixed(2)} USD. + Payout method type: ${trolleyRecipientPayoutDetails.payoutMethod}. + `, + ); - feeAmount = +Math.min( + const paymentRelease = await this.createDbPaymentRelease( + userId, + paymentAmount, + connectedPaymentMethod.payment_method_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, - feePercent * paymentAmount, - ).toFixed(2); + }, + ); - paymentAmount -= feeAmount; - } + const paymentBatch = await this.trolleyService.startBatchPayment( + `${userId}_${userHandle}`, + ); - this.logger.log( - ` - Total amount won: $${totalAmount.toFixed(2)} USD, to be paid: $${paymentAmount.toFixed(2)} USD. - Payout method type: ${trolleyRecipientPayoutDetails.payoutMethod}. - `, - ); + const trolleyPayment = await this.trolleyService.createPayment( + dbTrolleyRecipient.trolley_id, + paymentBatch.id, + paymentAmount, + paymentRelease.payment_release_id, + paymentMemo, + ); - const paymentRelease = await this.createDbPaymentRelease( - tx, - userId, - paymentAmount, - connectedPaymentMethod.payment_method_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, - }, - ); + await this.updateDbReleaseRecord(paymentRelease, { + externalTxId: trolleyPayment.id, + }); - const paymentBatch = await this.trolleyService.startBatchPayment( - `${userId}_${userHandle}`, + try { + await this.paymentsService.updatePaymentProcessingState( + winningsIds, + payment_status.PROCESSING, + ); + } catch (e) { + this.logger.error( + `Failed to update payment processing state: ${e?.message} for winnings '${winningsIds.join(',')}`, ); - const trolleyPayment = await this.trolleyService.createPayment( - dbTrolleyRecipient.trolley_id, + // mark release as failed + await this.updateDbReleaseRecord(paymentRelease, { + status: 'FAILED', + }); + + await this.trolleyService.removePayment( + trolleyPayment.id, paymentBatch.id, - paymentAmount, - paymentRelease.payment_release_id, - paymentMemo, ); - await this.updateDbReleaseRecord(tx, paymentRelease, trolleyPayment.id); + throw new Error('Failed to update payment processing state!'); + } - try { - await this.paymentsService.updatePaymentProcessingState( - winningsIds, - payment_status.PROCESSING, - tx, - ); - } catch (e) { - this.logger.error( - `Failed to update payment processing state: ${e.message} for winnings '${winningsIds.join(',')}`, - ); - throw new Error('Failed to update payment processing state!'); - } + try { + await this.trolleyService.startProcessingPayment(paymentBatch.id); + } catch (error) { + const errorMsg = `Failed to release payment: ${error.message}`; + this.logger.error(errorMsg, error); - try { - await this.trolleyService.startProcessingPayment(paymentBatch.id); - } catch (error) { - const errorMsg = `Failed to release payment: ${error.message}`; - this.logger.error(errorMsg, error); - throw new Error(errorMsg); - } + // revert to owed + await this.paymentsService.updatePaymentProcessingState( + winningsIds, + payment_status.OWED, + ); - 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, + // mark release as failed + await this.updateDbReleaseRecord(paymentRelease, { + status: 'FAILED', + }); + + throw new Error(errorMsg); + } + + try { + for (const winning of winnings) { + const payoutData: WithdrawUpdateData = { + userId: +userId, + status: 'Paid', + datePaid: formatDate(new Date()), + }; + + await this.tcChallengesService.updateLegacyPayments( + winning.externalId as string, + payoutData, ); } - }); + } catch (error) { + this.logger.error( + `Failed to update legacy payment while withdrawing for challenge ${error?.message ?? error}`, + error, + ); + } } catch (error) { if (error.code === 'P2010' && error.meta?.code === '55P03') { this.logger.error( diff --git a/src/dto/winning.dto.ts b/src/dto/winning.dto.ts index c8f6791..3e7110c 100644 --- a/src/dto/winning.dto.ts +++ b/src/dto/winning.dto.ts @@ -20,7 +20,6 @@ import { export enum WinningsType { PAYMENT = 'PAYMENT', - REWARD = 'REWARD', } export enum WinningsCategory { diff --git a/src/shared/global/otp.service.ts b/src/shared/global/otp.service.ts index e1256c0..2ba009a 100644 --- a/src/shared/global/otp.service.ts +++ b/src/shared/global/otp.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from './prisma.service'; import crypto from 'crypto'; -import { reference_type } from '@prisma/client'; +import { otp, reference_type } from '@prisma/client'; import { ENV_CONFIG } from 'src/config'; import { TopcoderEmailService } from '../topcoder/tc-email.service'; import { BasicMemberInfo } from '../topcoder'; @@ -104,62 +104,65 @@ export class OtpService { 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.`, - }; - } + return await this.prisma.$transaction(async (tx) => { + const records = await tx.$queryRaw` + SELECT id, email, otp_hash, expiration_time, action_type, created_at, updated_at, verified_at + FROM otp + WHERE otp_hash=${hashOtp(otpCode)} + ORDER BY expiration_time DESC + LIMIT 1 + FOR UPDATE NOWAIT; + `; + const record = records[0]; + + 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}`, + ); - this.logger.log( - `OTP code ${otpCode} verified successfully for action ${actionType}`, - ); + await tx.otp.update({ + where: { + id: record.id, + }, + data: { + verified_at: new Date(), + }, + }); - await this.prisma.otp.update({ - where: { - id: record.id, - }, - data: { - verified_at: new Date(), - }, + return { code: 'success' }; }); - - return { code: 'success' }; } } diff --git a/src/shared/global/trolley.service.ts b/src/shared/global/trolley.service.ts index cb6b235..e9b66a6 100644 --- a/src/shared/global/trolley.service.ts +++ b/src/shared/global/trolley.service.ts @@ -150,6 +150,20 @@ export class TrolleyService { } } + async removePayment(paymentId: string, batchId: string) { + try { + await this.client.payment.remove(paymentId, batchId); + this.logger.debug(`Removed trolley payment with id ${paymentId}`); + } catch (error) { + this.logger.error( + `Failed to remove trolley payment: '${error.message}'!`, + paymentId, + batchId, + error, + ); + } + } + async startProcessingPayment(paymentBatchId: string) { try { // generate quote diff --git a/src/shared/payments/payments.service.ts b/src/shared/payments/payments.service.ts index 2bdc642..3a49efc 100644 --- a/src/shared/payments/payments.service.ts +++ b/src/shared/payments/payments.service.ts @@ -109,12 +109,9 @@ export class PaymentsService { async updatePaymentProcessingState( winningsIds: string[], status: payment_status, - transaction?: Prisma.TransactionClient, ) { try { - const prismaClient = transaction || this.prisma; - - const r = await prismaClient.payment.updateMany({ + const r = await this.prisma.payment.updateMany({ where: { winnings_id: { in: winningsIds }, },