Skip to content

Commit 7c485b9

Browse files
committed
PM-1100 - endpoint for fetching trolley portal url
1 parent 3c745aa commit 7c485b9

File tree

18 files changed

+1248
-875
lines changed

18 files changed

+1248
-875
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +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",
28+
"@prisma/client": "^6.5.0",
2929
"axios": "^1.8.4",
3030
"class-transformer": "^0.5.1",
3131
"class-validator": "^0.14.1",
@@ -36,6 +36,7 @@
3636
"lodash": "^4.17.21",
3737
"reflect-metadata": "^0.2.2",
3838
"rxjs": "^7.8.1",
39+
"trolleyhq": "^1.1.0",
3940
"winston": "^3.17.0"
4041
},
4142
"devDependencies": {
@@ -57,7 +58,7 @@
5758
"globals": "^15.14.0",
5859
"jest": "^29.7.0",
5960
"prettier": "^3.4.2",
60-
"prisma": "^6.3.1",
61+
"prisma": "^6.5.0",
6162
"source-map-support": "^0.5.21",
6263
"supertest": "^7.0.0",
6364
"ts-jest": "^29.2.5",

pnpm-lock.yaml

Lines changed: 827 additions & 868 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 22 additions & 0 deletions
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

Lines changed: 9 additions & 0 deletions
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

Lines changed: 7 additions & 1 deletion
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,
Lines changed: 11 additions & 0 deletions
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 {}
Lines changed: 34 additions & 0 deletions
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+
}
Lines changed: 156 additions & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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 {

0 commit comments

Comments
 (0)