Skip to content

Commit 2e9532e

Browse files
committed
PM-1073 - trolley webhook handler
1 parent baf6f42 commit 2e9532e

File tree

12 files changed

+353
-4
lines changed

12 files changed

+353
-4
lines changed

.editorconfig

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# EditorConfig is awesome: https://EditorConfig.org
2+
3+
# top-most EditorConfig file
4+
root = true
5+
6+
[*]
7+
indent_style = space
8+
indent_size = 2
9+
end_of_line = lf
10+
charset = utf-8
11+
trim_trailing_whitespace = true
12+
insert_final_newline = true
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- CreateEnum
2+
CREATE TYPE "webhook_status" AS ENUM ('error', 'processed', 'processing');
3+
4+
-- CreateTable
5+
CREATE TABLE "trolley_webhook_log" (
6+
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
7+
"event_id" TEXT NOT NULL,
8+
"event_time" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9+
"event_payload" TEXT NOT NULL,
10+
"event_model" TEXT,
11+
"event_action" TEXT,
12+
"status" "webhook_status" NOT NULL,
13+
"error_message" TEXT,
14+
"created_by" VARCHAR(80),
15+
"updated_by" VARCHAR(80),
16+
"created_at" TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
17+
"updated_at" TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
18+
19+
CONSTRAINT "trolley_webhook_log_pkey" PRIMARY KEY ("id")
20+
);
21+
22+
-- CreateIndex
23+
CREATE UNIQUE INDEX "trolley_webhook_log_event_id_key" ON "trolley_webhook_log"("event_id");

prisma/schema.prisma

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
generator client {
2-
provider = "prisma-client-js"
2+
provider = "prisma-client-js"
33
previewFeatures = ["extendedIndexes"]
4-
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
4+
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
55
}
66

77
datasource db {
@@ -210,6 +210,27 @@ model winnings {
210210
origin origin? @relation(fields: [origin_id], references: [origin_id], onDelete: NoAction, onUpdate: NoAction)
211211
}
212212

213+
enum webhook_status {
214+
error
215+
processed
216+
processing
217+
}
218+
219+
model trolley_webhook_log {
220+
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
221+
event_id String @unique
222+
event_time DateTime @default(now()) @db.Timestamp(6)
223+
event_payload String
224+
event_model String?
225+
event_action String?
226+
status webhook_status
227+
error_message String?
228+
created_by String? @db.VarChar(80)
229+
updated_by String? @db.VarChar(80)
230+
created_at DateTime? @default(now()) @db.Timestamp(6)
231+
updated_at DateTime? @default(now()) @db.Timestamp(6)
232+
}
233+
213234
enum action_type {
214235
INITIATE_WITHDRAWAL
215236
ADD_WITHDRAWAL_METHOD

src/api/api.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import { TopcoderModule } from 'src/shared/topcoder/topcoder.module';
1515
import { OriginRepository } from './repository/origin.repo';
1616
import { TaxFormRepository } from './repository/taxForm.repo';
1717
import { PaymentMethodRepository } from './repository/paymentMethod.repo';
18+
import { WebhooksModule } from './webhooks/webhooks.module';
1819

1920
@Module({
20-
imports: [GlobalProvidersModule, TopcoderModule],
21+
imports: [WebhooksModule, GlobalProvidersModule, TopcoderModule],
2122
controllers: [
2223
HealthCheckController,
2324
AdminWinningController,
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { SetMetadata } from '@nestjs/common';
2+
3+
export const WEBHOOK_EVENT_METADATA_KEY = 'WH_EVENT_TYPE';
4+
export const WebhookEvent = (...events: string[]) =>
5+
SetMetadata(WEBHOOK_EVENT_METADATA_KEY, events);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Provider } from '@nestjs/common';
2+
import { PaymentHandler } from './payment.handler';
3+
import { TrolleyWebhookEvent } from '../webhooks.types';
4+
import { Reflector } from '@nestjs/core';
5+
import { WEBHOOK_EVENT_METADATA_KEY } from './decorators';
6+
7+
/**
8+
* Factory function to create a map of Trolley webhook event handlers.
9+
*
10+
* This function iterates over the provided handler classes and inspects their methods
11+
* to find those annotated with specific metadata indicating the Trolley webhook events
12+
* they handle. It then binds these methods to their respective event types and stores
13+
* them in a map for easy lookup.
14+
*
15+
* @param reflector - An instance of `Reflector` used to retrieve metadata from methods.
16+
* @param handlerClasses - An array of handler class instances containing methods
17+
* annotated with Trolley webhook event metadata.
18+
* @returns A `Map` where the keys are `TrolleyWebhookEvent` types and the values are
19+
* bound handler functions for those events.
20+
*/
21+
const trolleyHandlerFnsFactory = (reflector: Reflector, handlerClasses) => {
22+
const handlersMap = new Map<TrolleyWebhookEvent, () => void>();
23+
24+
for (const handlerClass of handlerClasses) {
25+
const prototype = Object.getPrototypeOf(handlerClass);
26+
for (const propertyName of Object.getOwnPropertyNames(prototype)) {
27+
const method = prototype[propertyName];
28+
if (typeof method !== 'function' || propertyName === 'constructor') {
29+
continue;
30+
}
31+
32+
const eventTypes = reflector.get<TrolleyWebhookEvent[]>(
33+
WEBHOOK_EVENT_METADATA_KEY,
34+
method,
35+
);
36+
37+
if (eventTypes?.length > 0) {
38+
eventTypes.forEach((eventType) => {
39+
handlersMap.set(eventType, method.bind(handlerClass));
40+
console.log(`Found event handler: ${eventType} -> ${propertyName}`);
41+
});
42+
}
43+
}
44+
}
45+
46+
return handlersMap;
47+
};
48+
49+
export const TrolleyWebhookHandlersProviders: Provider[] = [
50+
PaymentHandler,
51+
{
52+
provide: 'TrolleyWebhookHandlers',
53+
useFactory: (paymentHandler: PaymentHandler) => [paymentHandler],
54+
inject: [PaymentHandler],
55+
},
56+
{
57+
provide: 'trolleyHandlerFns',
58+
useFactory: trolleyHandlerFnsFactory,
59+
inject: [Reflector, 'TrolleyWebhookHandlers'],
60+
},
61+
];
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { TrolleyWebhookEvent } from '../webhooks.types';
3+
import { WebhookEvent } from './decorators';
4+
5+
@Injectable()
6+
export class PaymentHandler {
7+
@WebhookEvent(TrolleyWebhookEvent.paymentCreated)
8+
async handlePaymentCreated(payload: any): Promise<any> {
9+
// TODO: Build out logic for payment.created event
10+
console.log('handling', TrolleyWebhookEvent.paymentCreated);
11+
12+
}
13+
14+
@WebhookEvent(TrolleyWebhookEvent.paymentUpdated)
15+
async handlePaymentUpdated(payload: any): Promise<any> {
16+
// TODO: Build out logic for payment.updated event
17+
console.log('handling', TrolleyWebhookEvent.paymentUpdated);
18+
}
19+
}

src/api/webhooks/trolley.service.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import crypto from 'crypto';
2+
import { Inject, Injectable } from '@nestjs/common';
3+
import { trolley_webhook_log, webhook_status } from '@prisma/client';
4+
import { PrismaService } from 'src/shared/global/prisma.service';
5+
6+
enum TrolleyHeaders {
7+
id = 'x-paymentrails-delivery',
8+
signature = 'x-paymentrails-signature',
9+
created = 'x-paymentrails-created',
10+
}
11+
12+
const trolleyWhHmac = process.env.TROLLEY_WH_HMAC;
13+
if (!trolleyWhHmac) {
14+
throw new Error('TROLLEY_WH_HMAC is not set!');
15+
}
16+
17+
/**
18+
* Service responsible for handling Trolley webhook operations.
19+
*/
20+
@Injectable()
21+
export class TrolleyService {
22+
constructor(
23+
@Inject('trolleyHandlerFns')
24+
private readonly handlers,
25+
private readonly prisma: PrismaService,
26+
) {}
27+
28+
/**
29+
* Validates the webhook signature to ensure the request is authentic.
30+
*
31+
* @param headers - The HTTP request headers containing the signature.
32+
* @param bodyPayload - The raw body payload of the webhook request.
33+
* @returns A boolean indicating whether the signature is valid.
34+
*/
35+
validateSignature(headers: Request['headers'], bodyPayload: string): boolean {
36+
if (!headers[TrolleyHeaders.signature]) {
37+
return false;
38+
}
39+
40+
const headerSignatureValues = (
41+
headers[TrolleyHeaders.signature] ?? ''
42+
).split(',');
43+
44+
const t = headerSignatureValues[0].split('=')[1];
45+
const v1 = headerSignatureValues[1].split('=')[1];
46+
47+
const hmac = crypto.createHmac('sha256', trolleyWhHmac as string);
48+
hmac.update(`${t}${bodyPayload}`);
49+
const digest = hmac.digest('hex');
50+
51+
return digest === v1;
52+
}
53+
54+
/**
55+
* Validates whether the webhook event is unique by checking its ID against the database.
56+
*
57+
* @param headers - The HTTP request headers containing the webhook ID.
58+
* @returns A promise that resolves to a boolean indicating whether the webhook event is unique.
59+
*/
60+
async validateUnique(headers: Request['headers']): Promise<boolean> {
61+
const requestId = headers[TrolleyHeaders.id];
62+
const whEvent = await this.prisma.trolley_webhook_log.findUnique({
63+
where: { event_id: requestId },
64+
});
65+
return !whEvent;
66+
}
67+
68+
/**
69+
* Tracks the webhook events status by Updating or creating a record in the `trolley_webhook_log` table with the given event details.
70+
*
71+
* @param requestId - The unique identifier for the webhook event.
72+
* @param status - The status of the webhook event.
73+
* @param payload - (Optional) The payload associated with the webhook event.
74+
* @param meta - (Optional) Additional metadata for the webhook event, such as event time.
75+
* @returns A promise that resolves to the upserted `trolley_webhook_log` record.
76+
*/
77+
setEventState(
78+
requestId: string,
79+
status: webhook_status,
80+
payload?: any,
81+
meta?: Partial<trolley_webhook_log>,
82+
) {
83+
return this.prisma.trolley_webhook_log.upsert({
84+
where: {
85+
event_id: requestId,
86+
},
87+
create: {
88+
event_id: requestId,
89+
event_payload: JSON.stringify(payload ?? {}),
90+
event_time: meta?.event_time,
91+
event_model: payload?.model ?? '',
92+
event_action: payload?.action ?? '',
93+
status,
94+
created_by: 'system',
95+
} as trolley_webhook_log,
96+
update: {
97+
status,
98+
...meta,
99+
},
100+
});
101+
}
102+
103+
/**
104+
* Handles incoming webhook events by processing the payload and delegating
105+
* the event to the appropriate handler based on the model and action.
106+
*
107+
* @param headers - The headers of the incoming request, containing metadata
108+
* such as the event ID and creation time.
109+
* @param payload - The body of the webhook event, containing details such as
110+
* the model, action, and event-specific data.
111+
*/
112+
async handleEvent(headers: Request['headers'], payload: any) {
113+
const requestId = headers[TrolleyHeaders.id];
114+
115+
try {
116+
await this.setEventState(requestId, webhook_status.processing, payload, {
117+
event_time: headers[TrolleyHeaders.created],
118+
});
119+
120+
const { model, action, body } = payload;
121+
const handler = this.handlers.get(`${model}.${action}`);
122+
if (!handler) {
123+
throw new Error('Webhook event handler not found!');
124+
}
125+
126+
await handler(body);
127+
await this.setEventState(requestId, webhook_status.processed);
128+
} catch (e) {
129+
console.log(e);
130+
await this.setEventState(requestId, webhook_status.error, void 0, {
131+
error_message: e.message ?? e,
132+
});
133+
}
134+
}
135+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
Controller,
3+
Post,
4+
BadRequestException,
5+
Req,
6+
RawBodyRequest,
7+
InternalServerErrorException,
8+
ConflictException,
9+
} from '@nestjs/common';
10+
import { ApiTags } from '@nestjs/swagger';
11+
import { TrolleyService } from './trolley.service';
12+
import { Public } from 'src/core/auth/decorators';
13+
14+
@Public()
15+
@ApiTags('Webhooks')
16+
@Controller('webhooks')
17+
export class WebhooksController {
18+
constructor(private readonly trolleyService: TrolleyService) {}
19+
20+
/**
21+
* Handles incoming trolley webhooks.
22+
*
23+
* This method validates the webhook request by checking its signature and ensuring
24+
* it has not been processed before. If validation passes, it processes the webhook
25+
* payload and marks it as processed.
26+
*
27+
* @param request - The incoming webhook request containing headers, raw body, and parsed body.
28+
* @returns A success message if the webhook is processed successfully.
29+
* @throws {BadRequestException} If the signature is invalid or the webhook has already been processed.
30+
*/
31+
@Post('trolley')
32+
async handleTrolleyWebhook(@Req() request: RawBodyRequest<Request>) {
33+
if (
34+
!this.trolleyService.validateSignature(
35+
request.headers,
36+
request.rawBody?.toString('utf-8') ?? '',
37+
)
38+
) {
39+
throw new BadRequestException('Missing or invalid signature!');
40+
}
41+
42+
if (!(await this.trolleyService.validateUnique(request.headers))) {
43+
throw new ConflictException('Webhook already processed!');
44+
}
45+
46+
try {
47+
return this.trolleyService.handleEvent(request.headers, request.body);
48+
} catch (e) {
49+
console.log('Error processing the webhook!', e);
50+
throw new InternalServerErrorException('Error processing the webhook!');
51+
}
52+
}
53+
}

src/api/webhooks/webhooks.module.ts

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 { TrolleyService } from './trolley.service';
3+
import { WebhooksController } from './webhooks.controller';
4+
import { TrolleyWebhookHandlersProviders } from './trolley-handlers';
5+
6+
@Module({
7+
imports: [],
8+
controllers: [WebhooksController],
9+
providers: [...TrolleyWebhookHandlersProviders, TrolleyService],
10+
})
11+
export class WebhooksModule {}

src/api/webhooks/webhooks.types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export enum TrolleyWebhookEvent {
2+
paymentCreated = 'payment.created',
3+
paymentUpdated = 'payment.updated',
4+
}
5+
6+
export type TrolleyEventHandler = (eventPayload: any) => Promise<unknown>;

src/main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { ApiModule } from './api/api.module';
77
import { AppModule } from './app.module';
88

99
async function bootstrap() {
10-
const app = await NestFactory.create<NestExpressApplication>(AppModule);
10+
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
11+
rawBody: true,
12+
});
1113

1214
// Global prefix for all routes is configured as `/v5/finance`
1315
app.setGlobalPrefix(process.env.API_BASE ?? '/v5/finance');

0 commit comments

Comments
 (0)