Skip to content

PM-1100 - endpoint for fetching trolley portal url #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 18, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.0.3",
"@prisma/client": "^6.3.1",
"@prisma/client": "^6.5.0",
"axios": "^1.8.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
Expand All @@ -36,6 +36,7 @@
"lodash": "^4.17.21",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"trolleyhq": "^1.1.0",
"winston": "^3.17.0"
},
"devDependencies": {
Expand All @@ -57,7 +58,7 @@
"globals": "^15.14.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^6.3.1",
"prisma": "^6.5.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
Expand Down
1,695 changes: 827 additions & 868 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "trolley_recipient" (
"id" SERIAL NOT NULL,
"user_payment_method_id" UUID NOT NULL,
"user_id" VARCHAR(80) NOT NULL,
"trolley_id" VARCHAR(80) NOT NULL,

CONSTRAINT "trolley_recipient_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "trolley_recipient_user_id_key" ON "trolley_recipient"("user_id");

-- CreateIndex
CREATE UNIQUE INDEX "trolley_recipient_trolley_id_key" ON "trolley_recipient"("trolley_id");

-- AddForeignKey
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;

-- Insert Trolley payment method
INSERT INTO payment_method (payment_method_id, payment_method_type, name, description)
VALUES (50, 'Trolley', 'Trolley', 'Trolley is a modern payouts platform designed for the internet economy.');
9 changes: 9 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ model user_payment_methods {
status payment_method_status? @default(OTP_PENDING)
payoneer_payment_method payoneer_payment_method[]
paypal_payment_method paypal_payment_method[]
trolley_payment_method trolley_recipient[]
payment_method payment_method @relation(fields: [payment_method_id], references: [payment_method_id], onDelete: NoAction, onUpdate: NoAction, map: "fk_user_payment_method")

@@unique([user_id, payment_method_id])
Expand Down Expand Up @@ -210,6 +211,14 @@ model winnings {
origin origin? @relation(fields: [origin_id], references: [origin_id], onDelete: NoAction, onUpdate: NoAction)
}

model trolley_recipient {
id Int @id @default(autoincrement())
user_payment_method_id String @db.Uuid
user_id String @unique @db.VarChar(80)
trolley_id String @unique @db.VarChar(80)
user_payment_methods user_payment_methods @relation(fields: [user_payment_method_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "fk_trolley_user_payment_method")
}

enum webhook_status {
error
processed
Expand Down
8 changes: 7 additions & 1 deletion src/api/api.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@ import { OriginRepository } from './repository/origin.repo';
import { TaxFormRepository } from './repository/taxForm.repo';
import { PaymentMethodRepository } from './repository/paymentMethod.repo';
import { WebhooksModule } from './webhooks/webhooks.module';
import { PaymentProvidersModule } from './payment-providers/payment-providers.module';

@Module({
imports: [WebhooksModule, GlobalProvidersModule, TopcoderModule],
imports: [
GlobalProvidersModule,
TopcoderModule,
PaymentProvidersModule,
WebhooksModule,
],
controllers: [
HealthCheckController,
AdminWinningController,
Expand Down
11 changes: 11 additions & 0 deletions src/api/payment-providers/payment-providers.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { TopcoderModule } from 'src/shared/topcoder/topcoder.module';
import { TrolleyController } from './trolley.controller';
import { TrolleyService } from './trolley.service';

@Module({
imports: [TopcoderModule],
controllers: [TrolleyController],
providers: [TrolleyService],
})
export class PaymentProvidersModule {}
34 changes: 34 additions & 0 deletions src/api/payment-providers/trolley.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { TrolleyService } from './trolley.service';
import { Roles, User } from 'src/core/auth/decorators';
import { UserInfo } from 'src/dto/user.dto';
import { Role } from 'src/core/auth/auth.constants';
import { ResponseDto } from 'src/dto/adminWinning.dto';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ResponseDto type seems to be imported from adminWinning.dto, which might not be the most appropriate place for a DTO related to payment providers. Consider creating a new DTO file or moving it to a more relevant location.


@ApiTags('PaymentProviders')
@Controller('/trolley')
@ApiBearerAuth()
export class TrolleyController {
constructor(private readonly trolleyService: TrolleyService) {}

@Get('/portal-link')
@Roles(Role.User)
@ApiOperation({
summary: 'Get the Trolley portal link for the current user.',
})
@ApiResponse({
status: 200,
description: 'Trolley portal link',
type: ResponseDto<{ link: string; recipientId: string }>,
})
@HttpCode(HttpStatus.OK)
async getPortalUrl(@User() user: UserInfo) {
return this.trolleyService.getPortalUrlForUser(user);
}
}
156 changes: 156 additions & 0 deletions src/api/payment-providers/trolley.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { Injectable } from '@nestjs/common';
import { UserInfo } from 'src/dto/user.dto';
import { TrolleyService as Trolley } from 'src/shared/global/trolley.service';
import { PrismaService } from 'src/shared/global/prisma.service';
import { BASIC_MEMBER_FIELDS } from 'src/shared/topcoder';
import { TopcoderMembersService } from 'src/shared/topcoder/members.service';

@Injectable()
export class TrolleyService {
constructor(
private readonly trolley: Trolley,
private readonly prisma: PrismaService,
private readonly tcMembersService: TopcoderMembersService,
) {}

/**
* Retrieves the Trolley payment method record from the database.
* Throws an error if the record does not exist.
*/
private async getTrolleyPaymentMethod() {
const method = await this.prisma.payment_method.findUnique({
where: { payment_method_type: 'Trolley' },

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using a more specific error type instead of a generic Error to provide better error handling and debugging.

});

if (!method) {
throw new Error("DB record for payment method 'Trolley' not found!");
}

return method;
}

/**
* Attempts to find an existing Trolley recipient by email.
* If none exists, creates a new one using data fetched from member api
*
* @param user - Current user
*/
private async findOrCreateTrolleyRecipient(user: UserInfo) {
const foundRecipient = await this.trolley.client.recipient.search(
1,
1,
user.email,
);

if (foundRecipient?.length === 1) {
return foundRecipient[0];
}

const userInfo = await this.tcMembersService.getMemberInfoByUserHandle(
user.handle,
{ fields: BASIC_MEMBER_FIELDS },
);
const address = userInfo.addresses?.[0] ?? {};

const recipientPayload = {
type: 'individual' as const,
referenceId: user.id,
firstName: userInfo.firstName,
lastName: userInfo.lastName,
email: user.email,
address: {
city: address.city,
postalCode: address.zip,
region: address.stateCode,
street1: address.streetAddr1,
street2: address.streetAddr2,
},
};

return this.trolley.client.recipient.create(recipientPayload);
}

/**
* Creates and links a Trolley recipient with the user in the local DB.
* Uses a transaction to ensure consistency between user payment method creation
* and Trolley recipient linkage.
*
* @param user - Basic user info (e.g., ID, handle, email).
* @returns Trolley recipient DB model tied to the user.
*/
private async createPayeeRecipient(user: UserInfo) {
const recipient = await this.findOrCreateTrolleyRecipient(user);

const paymentMethod = await this.getTrolleyPaymentMethod();

return this.prisma.$transaction(async (tx) => {
let userPaymentMethod = await tx.user_payment_methods.findFirst({
where: {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The findFirst method might return null if no record is found. Ensure that subsequent operations handle this case appropriately.

user_id: user.id,
payment_method_id: paymentMethod.payment_method_id,
},
});

if (!userPaymentMethod) {
userPaymentMethod = await tx.user_payment_methods.create({
data: {
user_id: user.id,
payment_method: { connect: paymentMethod },
},
});
}

const updatedUserPaymentMethod = await tx.user_payment_methods.update({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding error handling for the update method to manage potential update failures.

where: { id: userPaymentMethod.id },
data: {
trolley_payment_method: {
create: {
user_id: user.id,
trolley_id: recipient.id,
},
},
},
include: {
trolley_payment_method: true,
},
});

return updatedUserPaymentMethod.trolley_payment_method?.[0];
});
}

/**
* Fetches the Trolley recipient associated with the given user.
* If none exists, creates and stores a new one.
*
* @param user - Basic user info
* @returns Trolley recipient DB model
*/
async getPayeeRecipient(user: UserInfo) {
const dbRecipient = await this.prisma.trolley_recipient.findUnique({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding error handling for the findUnique method to manage cases where the recipient cannot be retrieved.

where: { user_id: user.id },
});

if (dbRecipient) {
return dbRecipient;
}

return this.createPayeeRecipient(user);
}

/**
* Generates a portal URL for the user to access their Trolley dashboard.
*
* @param user - User information used to fetch Trolley recipient.
* @returns A URL string to the Trolley user portal.
*/
async getPortalUrlForUser(user: UserInfo) {
const recipient = await this.getPayeeRecipient(user);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding error handling for the getPayeeRecipient method to manage cases where the recipient cannot be retrieved or created.

const link = this.trolley.getRecipientPortalUrl({
email: user.email,
trolleyId: recipient.trolley_id,
});

return { link, recipientId: recipient.trolley_id };
}
}
1 change: 1 addition & 0 deletions src/core/auth/guards/roles.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class RolesGuard implements CanActivate {
request.user = {
id: userId,
handle: userHandle,
email: request.email,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request.email property is being used here, but it is not clear from the context if request.email is a valid property of the request object. Ensure that request.email is correctly set and available before this line to avoid potential runtime errors.

};

return true;
Expand Down
1 change: 1 addition & 0 deletions src/dto/user.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
export class UserInfo {
id: string;
handle: string;
email: string;
}

export class UserWinningRequestDto extends SortPagination {
Expand Down
4 changes: 3 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ApiModule } from './api/api.module';
import { AppModule } from './app.module';
import { PaymentProvidersModule } from './api/payment-providers/payment-providers.module';
import { WebhooksModule } from './api/webhooks/webhooks.module';

async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
Expand Down Expand Up @@ -49,7 +51,7 @@ async function bootstrap() {
})
.build();
const document = SwaggerModule.createDocument(app, config, {
include: [ApiModule],
include: [ApiModule, PaymentProvidersModule, WebhooksModule],

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Including PaymentProvidersModule and WebhooksModule in the Swagger document might not be necessary for the /trolley/portal-link endpoint unless these modules are directly related to the endpoint's functionality. Please ensure that these modules are required for the new endpoint and remove them if they are not relevant.

});
SwaggerModule.setup('/v5/finance/api-docs', app, document);

Expand Down
5 changes: 3 additions & 2 deletions src/shared/global/globalProviders.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { TrolleyService } from './trolley.service';

// Global module for providing global providers
// Add any provider you want to be global here
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
providers: [PrismaService, TrolleyService],
exports: [PrismaService, TrolleyService],
})
export class GlobalProvidersModule {}
54 changes: 54 additions & 0 deletions src/shared/global/trolley.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import url from 'url';
import crypto from 'crypto';
import trolley from 'trolleyhq';
import { Injectable } from '@nestjs/common';

const { TORLLEY_ACCESS_KEY, TORLLEY_SECRET_KEY, TROLLEY_WIDGET_BASE_URL } =

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in environment variable name: TORLLEY_ACCESS_KEY should be TROLLEY_ACCESS_KEY.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vas3a please check.

process.env;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in environment variable name: TORLLEY_SECRET_KEY should be TROLLEY_SECRET_KEY.


const client = trolley({
key: TORLLEY_ACCESS_KEY as string,
secret: TORLLEY_SECRET_KEY as string,
});

@Injectable()
export class TrolleyService {
get client() {
return client;
}

/**
* Generates a recipient-specific portal URL for the Trolley widget.
*
* @param recipient - recipient's details
* @returns A string representing the fully constructed and signed URL for the Trolley widget.
*
* @throws This function assumes that `TROLLEY_WIDGET_BASE_URL`, `TORLLEY_ACCESS_KEY`,
* and `TORLLEY_SECRET_KEY` are defined and valid. Ensure these constants are properly set.
*/
getRecipientPortalUrl(recipient: { email: string; trolleyId: string }) {
const widgetBaseUrl = new url.URL(TROLLEY_WIDGET_BASE_URL as string);
const querystring = new url.URLSearchParams({
ts: `${Math.floor(new Date().getTime() / 1000)}`,
key: TORLLEY_ACCESS_KEY,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in environment variable usage: TORLLEY_ACCESS_KEY should be TROLLEY_ACCESS_KEY.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vas3a please check

email: recipient.email,
refid: recipient.trolleyId,
hideEmail: 'false',
roEmail: 'true',
locale: 'en',
products: 'pay,tax',
} as Record<string, string>)
.toString()
.replace(/\+/g, '%20');

const hmac = crypto.createHmac('sha256', TORLLEY_SECRET_KEY as string);
hmac.update(querystring);

// Signature is only valid for 30 seconds
const signature = hmac.digest('hex');
widgetBaseUrl.search = querystring + '&sign=' + signature;

// you can send the link to your view engine
return widgetBaseUrl.toString();
}
}
Loading