diff --git a/serverless.yml b/serverless.yml index 455d0c6..ec8f4ef 100644 --- a/serverless.yml +++ b/serverless.yml @@ -67,6 +67,7 @@ provider: HOST_LINK_PQ: ${env:HOST_LINK_PQ} RAZORPAY_KEY_ID: ${env:RAZORPAY_KEY_ID} RAZORPAY_KEY_SECRET: ${env:RAZORPAY_KEY_SECRET} + RAZORPAY_WEBHOOK_SECRET: ${env:RAZORPAY_WEBHOOK_SECRET} iam: role: diff --git a/serverless/common.yml b/serverless/common.yml index 16372ba..ec4032e 100644 --- a/serverless/common.yml +++ b/serverless/common.yml @@ -59,6 +59,9 @@ provider: AM_INVITATION_LINK: ${env:AM_INVITATION_LINK} HOST_LINK: ${env:HOST_LINK} HOST_LINK_PQ: ${env:HOST_LINK_PQ} + RAZORPAY_KEY_ID: ${env:RAZORPAY_KEY_ID} + RAZORPAY_KEY_SECRET: ${env:RAZORPAY_KEY_SECRET} + RAZORPAY_WEBHOOK_SECRET: ${env:RAZORPAY_WEBHOOK_SECRET} iam: role: diff --git a/serverless/functions/user.yml b/serverless/functions/user.yml index 03affcf..55e0d06 100644 --- a/serverless/functions/user.yml +++ b/serverless/functions/user.yml @@ -513,6 +513,21 @@ verifyRazorpayPayment: path: /payment/verify-payment method: post +razorpayWebhook: + handler: src/modules/user/handlers/payment/razorpayWebhook.handler + memorySize: 512 + package: + patterns: + - 'src/modules/user/**' + - ${file(./serverless/patterns/base.yml):pattern1} + - ${file(./serverless/patterns/base.yml):pattern2} + - ${file(./serverless/patterns/base.yml):pattern3} + - ${file(./serverless/patterns/base.yml):pattern4} + events: + - httpApi: + path: /payment/webhook/razorpay + method: post + getMatchingBucketInterestedActivities: handler: src/modules/user/handlers/itinerary/getMatchingBucketInterestedActivities.handler memorySize: 512 diff --git a/src/config/config.ts b/src/config/config.ts index f08ade4..063ee60 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -87,6 +87,7 @@ const envVarsSchema = yup HOST_LINK_PQ: yup.string().required('Link to host panel pqp is required'), RAZORPAY_KEY_SECRET: yup.string().required('Razorpay key secret is required'), RAZORPAY_KEY_ID: yup.string().required('Razorpay key id is required'), + RAZORPAY_WEBHOOK_SECRET: yup.string().required('Razorpay webhook secret is required'), }) .noUnknown(true); @@ -169,6 +170,7 @@ function getConfig() { HOST_LINK_PQ: envVars.HOST_LINK_PQ, RAZORPAY_KEY_ID: envVars.RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET: envVars.RAZORPAY_KEY_SECRET, + RAZORPAY_WEBHOOK_SECRET: envVars.RAZORPAY_WEBHOOK_SECRET, // oneSignal: { // appID: envVars.ONESIGNAL_APPID, diff --git a/src/modules/user/handlers/payment/razorpayWebhook.ts b/src/modules/user/handlers/payment/razorpayWebhook.ts new file mode 100644 index 0000000..6f9ed8d --- /dev/null +++ b/src/modules/user/handlers/payment/razorpayWebhook.ts @@ -0,0 +1,63 @@ +import { + APIGatewayProxyEvent, + APIGatewayProxyResult, + Context, +} from 'aws-lambda'; + +import { prismaClient } from '../../../../common/database/prisma.lambda.service'; +import { safeHandler } from '../../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../../common/utils/helper/ApiError'; +import { PaymentService } from '../../services/payment.service'; + +const paymentService = new PaymentService(prismaClient); + +function getHeaderValue( + headers: APIGatewayProxyEvent['headers'], + name: string, +): string | undefined { + const targetName = name.toLowerCase(); + + for (const [key, value] of Object.entries(headers ?? {})) { + if (key.toLowerCase() === targetName) { + return typeof value === 'string' ? value : undefined; + } + } + + return undefined; +} + +export const handler = safeHandler(async ( + event: APIGatewayProxyEvent, + context?: Context, +): Promise => { + const signature = + getHeaderValue(event.headers, 'x-razorpay-signature')?.trim() ?? ''; + + if (!signature) { + throw new ApiError(400, 'Missing Razorpay webhook signature.'); + } + + const rawBody = event.body + ? event.isBase64Encoded + ? Buffer.from(event.body, 'base64').toString('utf8') + : event.body + : ''; + + const result = await paymentService.handleWebhook({ + rawBody, + signature, + }); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Webhook received', + data: result, + }), + }; +}); diff --git a/src/modules/user/services/payment.service.ts b/src/modules/user/services/payment.service.ts index 24af3f6..2d23c05 100644 --- a/src/modules/user/services/payment.service.ts +++ b/src/modules/user/services/payment.service.ts @@ -11,10 +11,42 @@ const razorpay = new Razorpay({ key_secret: config.RAZORPAY_KEY_SECRET, }); +type RazorpayWebhookPayload = { + event?: string; + payload?: { + payment?: { + entity?: { + id?: string; + order_id?: string; + status?: string; + amount?: number; + currency?: string; + }; + }; + order?: { + entity?: { + id?: string; + status?: string; + }; + }; + }; +}; + @Injectable() export class PaymentService { constructor(private prisma: PrismaClient) {} + private signaturesMatch(expectedSignature: string, receivedSignature: string) { + const expectedBuffer = Buffer.from(expectedSignature, 'utf8'); + const receivedBuffer = Buffer.from(receivedSignature, 'utf8'); + + if (expectedBuffer.length !== receivedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(expectedBuffer, receivedBuffer); + } + async createOrder( userXid: number, payload: { @@ -175,4 +207,156 @@ export class PaymentService { paidAt: updatedPaymentOrder.paidAt, }; } + + async handleWebhook(payload: { + rawBody: string; + signature: string; + }) { + const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET?.trim(); + + if (!webhookSecret) { + throw new ApiError( + 500, + 'Razorpay webhook secret is not configured.', + ); + } + + const rawBody = payload.rawBody; + const signature = payload.signature?.trim(); + + if (!rawBody) { + throw new ApiError(400, 'Webhook body is required.'); + } + + if (!signature) { + throw new ApiError(400, 'Razorpay webhook signature is required.'); + } + + const generatedSignature = crypto + .createHmac('sha256', webhookSecret) + .update(rawBody, 'utf8') + .digest('hex'); + + if (!this.signaturesMatch(generatedSignature, signature)) { + throw new ApiError(400, 'Invalid webhook signature.'); + } + + let body: RazorpayWebhookPayload; + try { + body = JSON.parse(rawBody) as RazorpayWebhookPayload; + } catch { + throw new ApiError(400, 'Invalid webhook JSON body.'); + } + + const eventType = body.event?.trim(); + if (!eventType) { + throw new ApiError(400, 'Webhook event type is missing.'); + } + + const paymentEntity = body.payload?.payment?.entity; + const orderEntity = body.payload?.order?.entity; + const razorpayOrderId = + paymentEntity?.order_id?.trim() || orderEntity?.id?.trim(); + const razorpayPaymentId = paymentEntity?.id?.trim(); + + if (!razorpayOrderId) { + throw new ApiError(400, 'Webhook payload is missing Razorpay order id.'); + } + + const paymentOrder = await this.prisma.paymentOrders.findFirst({ + where: { + razorpayOrderId, + isActive: true, + deletedAt: null, + }, + }); + + if (!paymentOrder) { + return { + eventType, + processed: false, + message: 'Payment order not found for webhook payload.', + }; + } + + if ( + eventType === 'payment.captured' && + paymentOrder.paymentStatus === 'paid' && + paymentOrder.razorpayPaymentId === razorpayPaymentId + ) { + return { + paymentOrderId: paymentOrder.id, + orderId: paymentOrder.razorpayOrderId, + paymentId: paymentOrder.razorpayPaymentId, + status: paymentOrder.paymentStatus, + verifiedAt: paymentOrder.verifiedAt, + paidAt: paymentOrder.paidAt, + eventType, + processed: true, + }; + } + + if (eventType === 'payment.captured' || eventType === 'order.paid') { + const updatedPaymentOrder = await this.prisma.paymentOrders.update({ + where: { + id: paymentOrder.id, + }, + data: { + ...(razorpayPaymentId + ? { razorpayPaymentId } + : {}), + paymentStatus: 'paid', + verifiedAt: new Date(), + paidAt: new Date(), + }, + }); + + return { + paymentOrderId: updatedPaymentOrder.id, + orderId: updatedPaymentOrder.razorpayOrderId, + paymentId: updatedPaymentOrder.razorpayPaymentId, + status: updatedPaymentOrder.paymentStatus, + verifiedAt: updatedPaymentOrder.verifiedAt, + paidAt: updatedPaymentOrder.paidAt, + eventType, + processed: true, + }; + } + + if (eventType === 'payment.failed') { + const updatedPaymentOrder = await this.prisma.paymentOrders.update({ + where: { + id: paymentOrder.id, + }, + data: { + ...(razorpayPaymentId + ? { razorpayPaymentId } + : {}), + paymentStatus: 'failed', + }, + }); + + return { + paymentOrderId: updatedPaymentOrder.id, + orderId: updatedPaymentOrder.razorpayOrderId, + paymentId: updatedPaymentOrder.razorpayPaymentId, + status: updatedPaymentOrder.paymentStatus, + verifiedAt: updatedPaymentOrder.verifiedAt, + paidAt: updatedPaymentOrder.paidAt, + eventType, + processed: true, + }; + } + + return { + paymentOrderId: paymentOrder.id, + orderId: paymentOrder.razorpayOrderId, + paymentId: paymentOrder.razorpayPaymentId, + status: paymentOrder.paymentStatus, + verifiedAt: paymentOrder.verifiedAt, + paidAt: paymentOrder.paidAt, + eventType, + processed: false, + }; + } }