Skip to content

Commit caf1188

Browse files
authored
Merge pull request #45 from topcoder-platform/PM-1099_withdraw-action
PM-1099 withdraw action
2 parents 38980d3 + 74c73cb commit caf1188

File tree

17 files changed

+573
-30
lines changed

17 files changed

+573
-30
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- AlterEnum
2+
-- This migration adds more than one value to an enum.
3+
-- With PostgreSQL versions 11 and earlier, this is not possible
4+
-- in a single migration. This can be worked around by creating
5+
-- multiple migrations, each migration adding only one value to
6+
-- the enum.
7+
8+
9+
ALTER TYPE "payment_status" ADD VALUE 'FAILED';
10+
ALTER TYPE "payment_status" ADD VALUE 'RETURNED';

prisma/schema.prisma

+2
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,8 @@ enum payment_status {
241241
OWED
242242
PROCESSING
243243
CANCELLED
244+
FAILED
245+
RETURNED
244246
}
245247

246248
enum reference_type {

src/api/api.module.ts

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { AdminModule } from './admin/admin.module';
1515
import { WinningsModule } from './winnings/winnings.module';
1616
import { UserModule } from './user/user.module';
1717
import { WalletModule } from './wallet/wallet.module';
18+
import { WithdrawalModule } from './withdrawal/withdrawal.module';
1819

1920
@Module({
2021
imports: [
@@ -26,6 +27,7 @@ import { WalletModule } from './wallet/wallet.module';
2627
WinningsModule,
2728
UserModule,
2829
WalletModule,
30+
WithdrawalModule,
2931
],
3032
controllers: [HealthCheckController],
3133
providers: [
+6-4
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import { Injectable } from '@nestjs/common';
2-
import { payment_method_status } from '@prisma/client';
2+
import { payment_method_status, user_payment_methods } from '@prisma/client';
33
import { PrismaService } from 'src/shared/global/prisma.service';
44

55
@Injectable()
66
export class PaymentMethodRepository {
77
constructor(private readonly prisma: PrismaService) {}
88

99
/**
10-
* Check user has verified payment method
10+
* Get the user's connected payment method (if there is one)
1111
*
1212
* @param userId user id
1313
* @param tx transaction
1414
*/
15-
async hasVerifiedPaymentMethod(userId: string): Promise<boolean> {
15+
async getConnectedPaymentMethod(
16+
userId: string,
17+
): Promise<user_payment_methods | null> {
1618
const connectedUserPaymentMethod =
1719
await this.prisma.user_payment_methods.findFirst({
1820
where: {
@@ -21,6 +23,6 @@ export class PaymentMethodRepository {
2123
},
2224
});
2325

24-
return !!connectedUserPaymentMethod;
26+
return connectedUserPaymentMethod;
2527
}
2628
}

src/api/wallet/wallet.service.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ export class WalletService {
3939
const winnings = await this.getWinningsTotalsByWinnerID(userId);
4040

4141
const hasActiveTaxForm = await this.taxFormRepo.hasActiveTaxForm(userId);
42-
const hasVerifiedPaymentMethod =
43-
await this.paymentMethodRepo.hasVerifiedPaymentMethod(userId);
42+
const hasVerifiedPaymentMethod = Boolean(
43+
await this.paymentMethodRepo.getConnectedPaymentMethod(userId),
44+
);
4445

4546
const winningTotals: WalletDetailDto = {
4647
account: {
@@ -62,10 +63,10 @@ export class WalletService {
6263
],
6364
},
6465
withdrawalMethod: {
65-
isSetupComplete: Boolean(hasVerifiedPaymentMethod),
66+
isSetupComplete: hasVerifiedPaymentMethod,
6667
},
6768
taxForm: {
68-
isSetupComplete: Boolean(hasActiveTaxForm),
69+
isSetupComplete: hasActiveTaxForm,
6970
},
7071
};
7172

Original file line numberDiff line numberDiff line change
@@ -1,11 +1,91 @@
1-
import { Injectable } from '@nestjs/common';
2-
// import { WebhookEvent } from '../../webhooks.decorators';
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import {
3+
PaymentProcessedEventData,
4+
PaymentProcessedEventStatus,
5+
PaymentWebhookEvent,
6+
} from './payment.types';
7+
import { WebhookEvent } from '../../webhooks.decorators';
8+
import { PaymentsService } from 'src/shared/payments';
9+
import { payment_status } from '@prisma/client';
10+
import { PrismaService } from 'src/shared/global/prisma.service';
11+
import { JsonObject } from '@prisma/client/runtime/library';
312

413
@Injectable()
514
export class PaymentHandler {
6-
// @WebhookEvent(TrolleyWebhookEvent.paymentCreated)
7-
// async handlePaymentCreated(payload: any): Promise<any> {
8-
// // TODO: Build out logic for payment.created event
9-
// console.log('handling', TrolleyWebhookEvent.paymentCreated);
10-
// }
15+
private readonly logger = new Logger(PaymentHandler.name);
16+
17+
constructor(
18+
private readonly prisma: PrismaService,
19+
private readonly paymentsService: PaymentsService,
20+
) {}
21+
22+
@WebhookEvent(
23+
PaymentWebhookEvent.processed,
24+
PaymentWebhookEvent.failed,
25+
PaymentWebhookEvent.returned,
26+
)
27+
async handlePaymentProcessed(
28+
payload: PaymentProcessedEventData,
29+
): Promise<any> {
30+
// TODO: remove slice-1
31+
const winningIds = (payload.externalId ?? '').split(',').slice(0, -1);
32+
const externalTransactionId = payload.batch.id;
33+
34+
if (!winningIds.length) {
35+
this.logger.error(
36+
`No valid winning IDs found in the externalId: ${payload.externalId}`,
37+
);
38+
throw new Error('No valid winning IDs found in the externalId!');
39+
}
40+
41+
if (payload.status !== PaymentProcessedEventStatus.PROCESSED) {
42+
await this.updatePaymentStates(
43+
winningIds,
44+
externalTransactionId,
45+
payload.status.toUpperCase() as payment_status,
46+
payload.status.toUpperCase(),
47+
{ failureMessage: payload.failureMessage },
48+
);
49+
50+
return;
51+
}
52+
53+
await this.updatePaymentStates(
54+
winningIds,
55+
externalTransactionId,
56+
payment_status.PAID,
57+
'PROCESSED',
58+
);
59+
}
60+
61+
private async updatePaymentStates(
62+
winningIds: string[],
63+
paymentId: string,
64+
processingState: payment_status,
65+
releaseState: string,
66+
metadata?: JsonObject,
67+
): Promise<void> {
68+
try {
69+
await this.prisma.$transaction(async (tx) => {
70+
await this.paymentsService.updatePaymentProcessingState(
71+
winningIds,
72+
processingState,
73+
tx,
74+
);
75+
76+
await this.paymentsService.updatePaymentReleaseState(
77+
paymentId,
78+
releaseState,
79+
tx,
80+
metadata,
81+
);
82+
});
83+
} catch (error) {
84+
this.logger.error(
85+
`Failed to update payment states for paymentId: ${paymentId}, winnings: ${winningIds.join(',')}, error: ${error.message}`,
86+
error.stack,
87+
);
88+
throw error;
89+
}
90+
}
1191
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export enum PaymentWebhookEvent {
2+
processed = 'payment.processed',
3+
failed = 'payment.failed',
4+
returned = 'payment.returned',
5+
}
6+
7+
export enum PaymentProcessedEventStatus {
8+
PROCESSED = 'processed',
9+
FAILED = 'failed',
10+
RETURNED = 'returned',
11+
}
12+
13+
export interface PaymentProcessedEventData {
14+
id: string;
15+
recipient: {
16+
id: string;
17+
referenceId: string;
18+
email: string;
19+
};
20+
status: PaymentProcessedEventStatus;
21+
externalId?: string;
22+
sourceAmount: string; // gross amount
23+
fees: string;
24+
targetAmount: string; // net amount
25+
failureMessage: string | null;
26+
memo: string | null;
27+
batch: {
28+
id: string;
29+
};
30+
}

src/api/webhooks/trolley/trolley.service.ts

+18-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import crypto from 'crypto';
2-
import { Inject, Injectable } from '@nestjs/common';
2+
import { Inject, Injectable, Logger } from '@nestjs/common';
33
import { trolley_webhook_log, webhook_status } from '@prisma/client';
44
import { PrismaService } from 'src/shared/global/prisma.service';
55
import { ENV_CONFIG } from 'src/config';
@@ -20,6 +20,8 @@ if (!trolleyWhHmac) {
2020
*/
2121
@Injectable()
2222
export class TrolleyService {
23+
private readonly logger = new Logger('Webhooks/TrolleyService');
24+
2325
constructor(
2426
@Inject('trolleyHandlerFns')
2527
private readonly handlers: Map<
@@ -118,22 +120,36 @@ export class TrolleyService {
118120
*/
119121
async handleEvent(headers: Request['headers'], payload: any) {
120122
const requestId = headers[TrolleyHeaders.id];
123+
this.logger.debug(`Received webhook event with ID: ${requestId}`);
121124

122125
try {
123126
await this.setEventState(requestId, webhook_status.logged, payload, {
124127
event_time: headers[TrolleyHeaders.created],
125128
});
126129

127130
const { model, action, body } = payload;
131+
this.logger.debug(`Processing event - ${requestId} - ${model}.${action}`);
132+
128133
const handler = this.handlers.get(`${model}.${action}`);
129-
// do nothing if there's no handler for the event (event was logged in db)
130134
if (!handler) {
135+
this.logger.debug(
136+
`No handler found for event - ${requestId} - ${model}.${action}. Event logged but not processed.`,
137+
);
131138
return;
132139
}
133140

141+
this.logger.debug(
142+
`Invoking handler for event - ${requestId} - ${model}.${action}`,
143+
);
134144
await handler(body[model]);
145+
146+
this.logger.debug(`Successfully processed event with ID: ${requestId}`);
135147
await this.setEventState(requestId, webhook_status.processed);
136148
} catch (e) {
149+
this.logger.error(
150+
`Error processing event with ID: ${requestId} - ${e.message ?? e}`,
151+
e.stack,
152+
);
137153
await this.setEventState(requestId, webhook_status.error, void 0, {
138154
error_message: e.message ?? e,
139155
});

src/api/winnings/winnings.service.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@ export class WinningsService {
7373
const hasActiveTaxForm = await this.taxFormRepo.hasActiveTaxForm(
7474
body.winnerId,
7575
);
76-
const hasPaymentMethod =
77-
await this.paymentMethodRepo.hasVerifiedPaymentMethod(body.winnerId);
76+
const hasConnectedPaymentMethod = Boolean(
77+
await this.paymentMethodRepo.getConnectedPaymentMethod(body.winnerId),
78+
);
7879

7980
for (const detail of body.details || []) {
8081
const paymentModel = {
@@ -90,7 +91,7 @@ export class WinningsService {
9091

9192
paymentModel.net_amount = Prisma.Decimal(detail.grossAmount);
9293
paymentModel.payment_status =
93-
hasPaymentMethod && hasActiveTaxForm
94+
hasConnectedPaymentMethod && hasActiveTaxForm
9495
? PaymentStatus.OWED
9596
: PaymentStatus.ON_HOLD;
9697

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsUUID } from 'class-validator';
3+
4+
export class WithdrawRequestDto {
5+
@ApiProperty({
6+
description: 'The ID of the winnings to withdraw',
7+
example: ['3fa85f64-5717-4562-b3fc-2c963f66afa6'],
8+
})
9+
@IsArray()
10+
@ArrayNotEmpty()
11+
@IsUUID('4',{ each: true })
12+
@IsNotEmpty({ each: true })
13+
winningsIds: string[];
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
Controller,
3+
Post,
4+
HttpCode,
5+
HttpStatus,
6+
Body,
7+
BadRequestException,
8+
} from '@nestjs/common';
9+
import {
10+
ApiOperation,
11+
ApiTags,
12+
ApiResponse,
13+
ApiBearerAuth,
14+
ApiBody,
15+
} from '@nestjs/swagger';
16+
17+
import { Role } from 'src/core/auth/auth.constants';
18+
import { Roles, User } from 'src/core/auth/decorators';
19+
import { UserInfo } from 'src/dto/user.type';
20+
import { ResponseDto, ResponseStatusType } from 'src/dto/api-response.dto';
21+
22+
import { WithdrawalService } from './withdrawal.service';
23+
import { WithdrawRequestDto } from './dto/withdraw.dto';
24+
25+
@ApiTags('Withdrawal')
26+
@Controller('/withdraw')
27+
@ApiBearerAuth()
28+
export class WithdrawalController {
29+
constructor(private readonly withdrawalService: WithdrawalService) {}
30+
31+
@Post()
32+
@Roles(Role.User)
33+
@ApiOperation({
34+
summary: 'User call this operation to process withdrawal.',
35+
description: 'jwt required.',
36+
})
37+
@ApiBody({
38+
description: 'Request body',
39+
type: WithdrawRequestDto,
40+
})
41+
@ApiResponse({
42+
status: 200,
43+
description: 'Operation successful.',
44+
type: ResponseDto<string>,
45+
})
46+
@HttpCode(HttpStatus.OK)
47+
async doWithdraw(
48+
@User() user: UserInfo,
49+
@Body() body: WithdrawRequestDto,
50+
): Promise<ResponseDto<string>> {
51+
const result = new ResponseDto<string>();
52+
53+
try {
54+
await this.withdrawalService.withdraw(
55+
user.id,
56+
user.handle,
57+
body.winningsIds,
58+
);
59+
result.status = ResponseStatusType.SUCCESS;
60+
return result;
61+
} catch (e) {
62+
throw new BadRequestException(e.message);
63+
}
64+
}
65+
}
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from '@nestjs/common';
2+
import { PaymentsModule } from 'src/shared/payments';
3+
import { WithdrawalController } from './withdrawal.controller';
4+
import { WithdrawalService } from './withdrawal.service';
5+
import { TaxFormRepository } from '../repository/taxForm.repo';
6+
import { PaymentMethodRepository } from '../repository/paymentMethod.repo';
7+
8+
@Module({
9+
imports: [PaymentsModule],
10+
controllers: [WithdrawalController],
11+
providers: [WithdrawalService, TaxFormRepository, PaymentMethodRepository],
12+
})
13+
export class WithdrawalModule {}

0 commit comments

Comments
 (0)