Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 9 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import swaggerUi from 'swagger-ui-express';
import { diMiddleware, useScopedContainer } from '@bonadocs/di';
import { rootLoggerMiddleware } from '@bonadocs/logger';

import { AppErrorHandler, AuthMiddleware, healthcheckMiddleware, useCors } from './middleware';
import {
AppErrorHandler,
AuthMiddleware,
healthcheckMiddleware,
useCors,
webhookMiddleware,
} from './middleware';
import {
AuthController,
BillingController,
Expand All @@ -26,6 +32,8 @@ const configService = getConfigService();
// add middleware
app.use(helmet());
useCors(app);
app.use('/v1/billing/webhook', express.raw({ type: '*/*' }));
app.use(webhookMiddleware);
app.use(express.json({ limit: '300kb' }));

// add this before the logger to avoid unnecessary healthcheck
Expand Down
2 changes: 1 addition & 1 deletion src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class AuthMiddleware implements ExpressMiddlewareInterface {

async use(request: Request, response: Response, next: (err?: any) => any): Promise<any> {
// todo : find a better fix for this
const exemptPaths = ['/auth/login', '/auth/register', '/auth/refresh'];
const exemptPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/billing/webhook'];
const isExemptPath = exemptPaths.some((path) => request.path.startsWith(path));
if (isExemptPath) {
return next();
Expand Down
1 change: 1 addition & 0 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { default as AppErrorHandler } from './errors';
export { default as healthcheckMiddleware } from './healthcheck';
export { useCors } from './cors';
export { AuthMiddleware } from './auth';
export { default as webhookMiddleware } from './webhook';
53 changes: 53 additions & 0 deletions src/middleware/webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextFunction, Request, Response } from 'express';
import { StatusCodes } from 'http-status-codes';
import Container from 'typedi';

import { getGlobalLogger } from '@bonadocs/di';

import { BillingService } from '../modules/billing/billing.service';

export default function webhookMiddleware(
request: Request,
response: Response,
next: NextFunction,
): void | Promise<void> {
const billingService = Container.get(BillingService);
const logger = getGlobalLogger();
if (request.path.startsWith('/v1/billing/webhook')) {
billingService
.verifyWebhookSignature(request)
.then(async (eventData) => {
if (eventData === null) {
return response.status(StatusCodes.BAD_REQUEST).json({
status: 'failed',
message: 'Unable to verify event signature',
});
}

const result = await billingService.handleEvent(eventData);

if (result) {
return response.status(StatusCodes.OK).json({
status: 'successful',
message: 'Event handled successfully',
});
}

return response.status(StatusCodes.BAD_REQUEST).json({
status: 'failed',
message: 'Unable to handle event',
});
})
.catch((error) => {
logger.error('Webhook middleware error:', error);
return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
status: 'failed',
message: 'Internal server error',
});
});

return; // prevent next() from being called prematurely
}

next();
}
82 changes: 15 additions & 67 deletions src/modules/billing/billing.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import { EventName, Paddle } from '@paddle/paddle-node-sdk';
import { Request } from 'express';
import { Body, Get, JsonController, Post, Req } from 'routing-controllers';
import { Body, Get, JsonController, Param, Post } from 'routing-controllers';
import { Inject, Service } from 'typedi';

import { diConstants } from '@bonadocs/di';
import { BonadocsLogger } from '@bonadocs/logger';

import { ConfigService, Paddle as PaddleConfig } from '../configuration';
import { Subscription } from '../repositories/billing';
import { PlanSummaryDto } from '../repositories/projects';
import { JsonResponse } from '../shared';
Expand All @@ -18,70 +12,23 @@ import { GeneratePaymentLinkDto } from './dto/generate-payment-link.dto';
@Service()
@JsonController('/billing')
export class BillingController {
constructor(
@Inject(diConstants.logger) private readonly logger: BonadocsLogger,
@Inject() private readonly billingService: BillingService,
@Inject() private readonly configService: ConfigService,
) {}
constructor(@Inject() private readonly billingService: BillingService) {}

@Post('/upgrade')
async create(
@Body({ validate: true }) payload: GeneratePaymentLinkDto,
@Req() request: Request,
): Promise<JsonResponse<BillingSubscriptionResponse>> {
const authData = request.auth;
await this.billingService.upgradePlan(authData.projectId!, payload.planId);
const url = await this.billingService.upgradePlan(
payload.projectId!,
payload.planId,
payload.countryCode,
);
return {
status: 'successful',
message: 'Subscription plan upgraded successfully',
};
}

@Post('/webhook')
async webhook(@Req() request: Request): Promise<JsonResponse<string> | void> {
const paddleConfigs = this.configService.getTransformed<PaddleConfig>('paddle');
const paddle = new Paddle(paddleConfigs.apiKey);
const signature = request.headers['paddle-signature'] as string;
const rawBody = request.body.toString();
const secretKey = paddleConfigs.webhookSecretKey;
try {
if (signature && rawBody) {
const eventData = await paddle.webhooks.unmarshal(rawBody, secretKey, signature);
switch (eventData.eventType) {
case EventName.TransactionPaid:
this.billingService.handleTransactionPaidWebhook(eventData.data);
this.logger.info(
`Handle transaction paid webhook event: ${eventData.notificationId} occurred at ${eventData.occurredAt}`,
);
return {
status: 'successful',
message: 'Webhook event handled successfully',
};
case EventName.SubscriptionCanceled:
this.billingService.handleSubscriptionCreatedWebhook(eventData.data);
this.logger.info(
`Handle subscription canceled webhook event: ${eventData.notificationId} occurred at ${eventData.occurredAt}`,
);
return {
status: 'successful',
message: 'Webhook event handled successfully',
};
default:
this.logger.warn(`Unhandled event type: ${eventData.eventType}`);
return {
status: 'error',
message: 'Webhook event could not be handled',
};
}
}
} catch (error) {
this.logger.info(
`An error occurred while handling webhook event: ${error} occurred at ${new Date().toISOString()}`,
);
}
return {
status: 'error',
message: 'Webhook event could not be handled',
data: {
url,
},
};
}

Expand All @@ -95,10 +42,11 @@ export class BillingController {
};
}

@Get('/active-subscription')
async getActiveSubscription(@Req() request: Request): Promise<JsonResponse<Subscription>> {
const authData = request.auth;
const data = await this.billingService.getActiveSubscriptionPlan(authData.projectId!);
@Get('/active-subscription/:projectId')
async getActiveSubscription(
@Param('projectId') projectId: number,
): Promise<JsonResponse<Subscription>> {
const data = await this.billingService.getActiveSubscriptionPlan(projectId);
return {
status: 'successful',
message: 'Plans successfully retrieved',
Expand Down
Loading
Loading