Skip to content

Commit 6290075

Browse files
authored
Merge pull request #29 from topcoder-platform/PM-1100_trolley-portal
PM-1100 - endpoint for fetching trolley portal url
2 parents 4c9ab94 + 47baaaf commit 6290075

18 files changed

+1261
-883
lines changed

package.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@
2525
"@nestjs/core": "^11.0.1",
2626
"@nestjs/platform-express": "^11.0.1",
2727
"@nestjs/swagger": "^11.0.3",
28-
"@prisma/client": "^6.3.1",
29-
"axios": "^1.8.4",
28+
"@prisma/client": "^6.5.0",
3029
"class-transformer": "^0.5.1",
3130
"class-validator": "^0.14.1",
3231
"cors": "^2.8.5",
@@ -36,6 +35,7 @@
3635
"lodash": "^4.17.21",
3736
"reflect-metadata": "^0.2.2",
3837
"rxjs": "^7.8.1",
38+
"trolleyhq": "^1.1.0",
3939
"winston": "^3.17.0"
4040
},
4141
"devDependencies": {
@@ -57,7 +57,7 @@
5757
"globals": "^15.14.0",
5858
"jest": "^29.7.0",
5959
"prettier": "^3.4.2",
60-
"prisma": "^6.3.1",
60+
"prisma": "^6.5.0",
6161
"source-map-support": "^0.5.21",
6262
"supertest": "^7.0.0",
6363
"ts-jest": "^29.2.5",

pnpm-lock.yaml

+827-871
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- CreateTable
2+
CREATE TABLE "trolley_recipient" (
3+
"id" SERIAL NOT NULL,
4+
"user_payment_method_id" UUID NOT NULL,
5+
"user_id" VARCHAR(80) NOT NULL,
6+
"trolley_id" VARCHAR(80) NOT NULL,
7+
8+
CONSTRAINT "trolley_recipient_pkey" PRIMARY KEY ("id")
9+
);
10+
11+
-- CreateIndex
12+
CREATE UNIQUE INDEX "trolley_recipient_user_id_key" ON "trolley_recipient"("user_id");
13+
14+
-- CreateIndex
15+
CREATE UNIQUE INDEX "trolley_recipient_trolley_id_key" ON "trolley_recipient"("trolley_id");
16+
17+
-- AddForeignKey
18+
ALTER TABLE "trolley_recipient" ADD CONSTRAINT "fk_trolley_user_payment_method" FOREIGN KEY ("user_payment_method_id") REFERENCES "user_payment_methods"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
19+
20+
-- Insert Trolley payment method
21+
INSERT INTO payment_method (payment_method_id, payment_method_type, name, description)
22+
VALUES (50, 'Trolley', 'Trolley', 'Trolley is a modern payouts platform designed for the internet economy.');

prisma/schema.prisma

+9
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ model user_payment_methods {
172172
status payment_method_status? @default(OTP_PENDING)
173173
payoneer_payment_method payoneer_payment_method[]
174174
paypal_payment_method paypal_payment_method[]
175+
trolley_payment_method trolley_recipient[]
175176
payment_method payment_method @relation(fields: [payment_method_id], references: [payment_method_id], onDelete: NoAction, onUpdate: NoAction, map: "fk_user_payment_method")
176177
177178
@@unique([user_id, payment_method_id])
@@ -210,6 +211,14 @@ model winnings {
210211
origin origin? @relation(fields: [origin_id], references: [origin_id], onDelete: NoAction, onUpdate: NoAction)
211212
}
212213

214+
model trolley_recipient {
215+
id Int @id @default(autoincrement())
216+
user_payment_method_id String @db.Uuid
217+
user_id String @unique @db.VarChar(80)
218+
trolley_id String @unique @db.VarChar(80)
219+
user_payment_methods user_payment_methods @relation(fields: [user_payment_method_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "fk_trolley_user_payment_method")
220+
}
221+
213222
enum webhook_status {
214223
error
215224
processed

src/api/api.module.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,15 @@ import { OriginRepository } from './repository/origin.repo';
1616
import { TaxFormRepository } from './repository/taxForm.repo';
1717
import { PaymentMethodRepository } from './repository/paymentMethod.repo';
1818
import { WebhooksModule } from './webhooks/webhooks.module';
19+
import { PaymentProvidersModule } from './payment-providers/payment-providers.module';
1920

2021
@Module({
21-
imports: [WebhooksModule, GlobalProvidersModule, TopcoderModule],
22+
imports: [
23+
GlobalProvidersModule,
24+
TopcoderModule,
25+
PaymentProvidersModule,
26+
WebhooksModule,
27+
],
2228
controllers: [
2329
HealthCheckController,
2430
AdminWinningController,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Module } from '@nestjs/common';
2+
import { TopcoderModule } from 'src/shared/topcoder/topcoder.module';
3+
import { TrolleyController } from './trolley.controller';
4+
import { TrolleyService } from './trolley.service';
5+
6+
@Module({
7+
imports: [TopcoderModule],
8+
controllers: [TrolleyController],
9+
providers: [TrolleyService],
10+
})
11+
export class PaymentProvidersModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common';
2+
import {
3+
ApiBearerAuth,
4+
ApiOperation,
5+
ApiResponse,
6+
ApiTags,
7+
} from '@nestjs/swagger';
8+
import { TrolleyService } from './trolley.service';
9+
import { Roles, User } from 'src/core/auth/decorators';
10+
import { UserInfo } from 'src/dto/user.dto';
11+
import { Role } from 'src/core/auth/auth.constants';
12+
import { ResponseDto } from 'src/dto/adminWinning.dto';
13+
14+
@ApiTags('PaymentProviders')
15+
@Controller('/trolley')
16+
@ApiBearerAuth()
17+
export class TrolleyController {
18+
constructor(private readonly trolleyService: TrolleyService) {}
19+
20+
@Get('/portal-link')
21+
@Roles(Role.User)
22+
@ApiOperation({
23+
summary: 'Get the Trolley portal link for the current user.',
24+
})
25+
@ApiResponse({
26+
status: 200,
27+
description: 'Trolley portal link',
28+
type: ResponseDto<{ link: string; recipientId: string }>,
29+
})
30+
@HttpCode(HttpStatus.OK)
31+
async getPortalUrl(@User() user: UserInfo) {
32+
return this.trolleyService.getPortalUrlForUser(user);
33+
}
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { UserInfo } from 'src/dto/user.dto';
3+
import { TrolleyService as Trolley } from 'src/shared/global/trolley.service';
4+
import { PrismaService } from 'src/shared/global/prisma.service';
5+
import { BASIC_MEMBER_FIELDS } from 'src/shared/topcoder';
6+
import { TopcoderMembersService } from 'src/shared/topcoder/members.service';
7+
8+
@Injectable()
9+
export class TrolleyService {
10+
constructor(
11+
private readonly trolley: Trolley,
12+
private readonly prisma: PrismaService,
13+
private readonly tcMembersService: TopcoderMembersService,
14+
) {}
15+
16+
/**
17+
* Retrieves the Trolley payment method record from the database.
18+
* Throws an error if the record does not exist.
19+
*/
20+
private async getTrolleyPaymentMethod() {
21+
const method = await this.prisma.payment_method.findUnique({
22+
where: { payment_method_type: 'Trolley' },
23+
});
24+
25+
if (!method) {
26+
throw new Error("DB record for payment method 'Trolley' not found!");
27+
}
28+
29+
return method;
30+
}
31+
32+
/**
33+
* Attempts to find an existing Trolley recipient by email.
34+
* If none exists, creates a new one using data fetched from member api
35+
*
36+
* @param user - Current user
37+
*/
38+
private async findOrCreateTrolleyRecipient(user: UserInfo) {
39+
const foundRecipient = await this.trolley.client.recipient.search(
40+
1,
41+
1,
42+
user.email,
43+
);
44+
45+
if (foundRecipient?.length === 1) {
46+
return foundRecipient[0];
47+
}
48+
49+
const userInfo = await this.tcMembersService.getMemberInfoByUserHandle(
50+
user.handle,
51+
{ fields: BASIC_MEMBER_FIELDS },
52+
);
53+
const address = userInfo.addresses?.[0] ?? {};
54+
55+
const recipientPayload = {
56+
type: 'individual' as const,
57+
referenceId: user.id,
58+
firstName: userInfo.firstName,
59+
lastName: userInfo.lastName,
60+
email: user.email,
61+
address: {
62+
city: address.city,
63+
postalCode: address.zip,
64+
region: address.stateCode,
65+
street1: address.streetAddr1,
66+
street2: address.streetAddr2,
67+
},
68+
};
69+
70+
return this.trolley.client.recipient.create(recipientPayload);
71+
}
72+
73+
/**
74+
* Creates and links a Trolley recipient with the user in the local DB.
75+
* Uses a transaction to ensure consistency between user payment method creation
76+
* and Trolley recipient linkage.
77+
*
78+
* @param user - Basic user info (e.g., ID, handle, email).
79+
* @returns Trolley recipient DB model tied to the user.
80+
*/
81+
private async createPayeeRecipient(user: UserInfo) {
82+
const recipient = await this.findOrCreateTrolleyRecipient(user);
83+
84+
const paymentMethod = await this.getTrolleyPaymentMethod();
85+
86+
return this.prisma.$transaction(async (tx) => {
87+
let userPaymentMethod = await tx.user_payment_methods.findFirst({
88+
where: {
89+
user_id: user.id,
90+
payment_method_id: paymentMethod.payment_method_id,
91+
},
92+
});
93+
94+
if (!userPaymentMethod) {
95+
userPaymentMethod = await tx.user_payment_methods.create({
96+
data: {
97+
user_id: user.id,
98+
payment_method: { connect: paymentMethod },
99+
},
100+
});
101+
}
102+
103+
const updatedUserPaymentMethod = await tx.user_payment_methods.update({
104+
where: { id: userPaymentMethod.id },
105+
data: {
106+
trolley_payment_method: {
107+
create: {
108+
user_id: user.id,
109+
trolley_id: recipient.id,
110+
},
111+
},
112+
},
113+
include: {
114+
trolley_payment_method: true,
115+
},
116+
});
117+
118+
return updatedUserPaymentMethod.trolley_payment_method?.[0];
119+
});
120+
}
121+
122+
/**
123+
* Fetches the Trolley recipient associated with the given user.
124+
* If none exists, creates and stores a new one.
125+
*
126+
* @param user - Basic user info
127+
* @returns Trolley recipient DB model
128+
*/
129+
async getPayeeRecipient(user: UserInfo) {
130+
const dbRecipient = await this.prisma.trolley_recipient.findUnique({
131+
where: { user_id: user.id },
132+
});
133+
134+
if (dbRecipient) {
135+
return dbRecipient;
136+
}
137+
138+
return this.createPayeeRecipient(user);
139+
}
140+
141+
/**
142+
* Generates a portal URL for the user to access their Trolley dashboard.
143+
*
144+
* @param user - User information used to fetch Trolley recipient.
145+
* @returns A URL string to the Trolley user portal.
146+
*/
147+
async getPortalUrlForUser(user: UserInfo) {
148+
const recipient = await this.getPayeeRecipient(user);
149+
const link = this.trolley.getRecipientPortalUrl({
150+
email: user.email,
151+
trolleyId: recipient.trolley_id,
152+
});
153+
154+
return { link, recipientId: recipient.trolley_id };
155+
}
156+
}

src/core/auth/guards/roles.guard.ts

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class RolesGuard implements CanActivate {
4949
request.user = {
5050
id: userId,
5151
handle: userHandle,
52+
email: request.email,
5253
};
5354

5455
return true;

src/dto/user.dto.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
export class UserInfo {
1111
id: string;
1212
handle: string;
13+
email: string;
1314
}
1415

1516
export class UserWinningRequestDto extends SortPagination {

src/main.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { ValidationPipe } from '@nestjs/common';
55
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
66
import { ApiModule } from './api/api.module';
77
import { AppModule } from './app.module';
8+
import { PaymentProvidersModule } from './api/payment-providers/payment-providers.module';
9+
import { WebhooksModule } from './api/webhooks/webhooks.module';
810

911
async function bootstrap() {
1012
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
@@ -49,7 +51,7 @@ async function bootstrap() {
4951
})
5052
.build();
5153
const document = SwaggerModule.createDocument(app, config, {
52-
include: [ApiModule],
54+
include: [ApiModule, PaymentProvidersModule, WebhooksModule],
5355
});
5456
SwaggerModule.setup('/v5/finance/api-docs', app, document);
5557

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Global, Module } from '@nestjs/common';
22
import { PrismaService } from './prisma.service';
3+
import { TrolleyService } from './trolley.service';
34

45
// Global module for providing global providers
56
// Add any provider you want to be global here
67
@Global()
78
@Module({
8-
providers: [PrismaService],
9-
exports: [PrismaService],
9+
providers: [PrismaService, TrolleyService],
10+
exports: [PrismaService, TrolleyService],
1011
})
1112
export class GlobalProvidersModule {}

src/shared/global/trolley.service.ts

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import url from 'url';
2+
import crypto from 'crypto';
3+
import trolley from 'trolleyhq';
4+
import { Injectable } from '@nestjs/common';
5+
6+
const { TROLLEY_ACCESS_KEY, TROLLEY_SECRET_KEY, TROLLEY_WIDGET_BASE_URL } =
7+
process.env;
8+
9+
const client = trolley({
10+
key: TROLLEY_ACCESS_KEY as string,
11+
secret: TROLLEY_SECRET_KEY as string,
12+
});
13+
14+
@Injectable()
15+
export class TrolleyService {
16+
get client() {
17+
return client;
18+
}
19+
20+
/**
21+
* Generates a recipient-specific portal URL for the Trolley widget.
22+
*
23+
* @param recipient - recipient's details
24+
* @returns A string representing the fully constructed and signed URL for the Trolley widget.
25+
*
26+
* @throws This function assumes that `TROLLEY_WIDGET_BASE_URL`, `TROLLEY_ACCESS_KEY`,
27+
* and `TROLLEY_SECRET_KEY` are defined and valid. Ensure these constants are properly set.
28+
*/
29+
getRecipientPortalUrl(recipient: { email: string; trolleyId: string }) {
30+
const widgetBaseUrl = new url.URL(TROLLEY_WIDGET_BASE_URL as string);
31+
const querystring = new url.URLSearchParams({
32+
ts: `${Math.floor(new Date().getTime() / 1000)}`,
33+
key: TROLLEY_ACCESS_KEY,
34+
email: recipient.email,
35+
refid: recipient.trolleyId,
36+
hideEmail: 'false',
37+
roEmail: 'true',
38+
locale: 'en',
39+
products: 'pay,tax',
40+
} as Record<string, string>)
41+
.toString()
42+
.replace(/\+/g, '%20');
43+
44+
const hmac = crypto.createHmac('sha256', TROLLEY_SECRET_KEY as string);
45+
hmac.update(querystring);
46+
47+
// Signature is only valid for 30 seconds
48+
const signature = hmac.digest('hex');
49+
widgetBaseUrl.search = querystring + '&sign=' + signature;
50+
51+
// you can send the link to your view engine
52+
return widgetBaseUrl.toString();
53+
}
54+
}

0 commit comments

Comments
 (0)