diff --git a/src/api/webhooks/trolley/handlers/payment.handler.ts b/src/api/webhooks/trolley/handlers/payment.handler.ts index e49ce76..e12a0a0 100644 --- a/src/api/webhooks/trolley/handlers/payment.handler.ts +++ b/src/api/webhooks/trolley/handlers/payment.handler.ts @@ -57,8 +57,8 @@ 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 diff --git a/src/api/withdrawal/withdrawal.service.ts b/src/api/withdrawal/withdrawal.service.ts index 8bd6b9c..c3a12d9 100644 --- a/src/api/withdrawal/withdrawal.service.ts +++ b/src/api/withdrawal/withdrawal.service.ts @@ -52,7 +52,7 @@ export class WithdrawalService { private readonly tcMembersService: TopcoderMembersService, ) {} - getTrolleyRecipientByUserId(userId: string) { + getDbTrolleyRecipientByUserId(userId: string) { return this.prisma.trolley_recipient.findUnique({ where: { user_id: userId }, }); @@ -118,6 +118,7 @@ export class WithdrawalService { paymentMethodId: number, recipientId: string, winnings: ReleasableWinningRow[], + metadata: any, ) { try { const paymentRelease = await tx.payment_releases.create({ @@ -127,6 +128,7 @@ export class WithdrawalService { status: payment_status.PROCESSING, payment_method_id: paymentMethodId, payee_id: recipientId, + metadata, payment_release_associations: { createMany: { data: winnings.map((w) => ({ @@ -176,7 +178,9 @@ export class WithdrawalService { winningsIds: string[], paymentMemo?: 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) { @@ -206,6 +210,9 @@ export class WithdrawalService { } 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.', ); @@ -219,23 +226,74 @@ 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. + Fee applied: $${feeAmount.toFixed(2)} USD (${Number(ENV_CONFIG.TROLLEY_PAYPAL_FEE_PERCENT)}%, max ${ENV_CONFIG.TROLLEY_PAYPAL_FEE_MAX_AMOUNT}). + 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( @@ -243,9 +301,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, ); diff --git a/src/config/config.env.ts b/src/config/config.env.ts index b57660f..5e0c7a5 100644 --- a/src/config/config.env.ts +++ b/src/config/config.env.ts @@ -3,8 +3,11 @@ import { IsBoolean, IsInt, IsNotEmpty, + IsNumber, IsOptional, IsString, + Max, + Min, } from 'class-validator'; export class ConfigEnv { @@ -90,4 +93,14 @@ export class ConfigEnv { @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; } diff --git a/src/shared/global/trolley.service.ts b/src/shared/global/trolley.service.ts index eee2307..7b88d85 100644 --- a/src/shared/global/trolley.service.ts +++ b/src/shared/global/trolley.service.ts @@ -118,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, }; @@ -191,7 +191,7 @@ export class TrolleyService { ]) 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;