From 282ccd96a884c243713322294c4ab2677f368df3 Mon Sep 17 00:00:00 2001 From: Akalanka Date: Mon, 20 Nov 2023 23:17:25 +0530 Subject: [PATCH] Feat: integrated payhere Closes #1 --- .env.example | 7 +- package.json | 2 +- pnpm-lock.yaml | 8 +- src/config/index.js | 8 + src/controllers/event/ticket.js | 24 +++ src/controllers/webhook.js | 15 ++ ...20162415-change_event_capacity_to_seats.js | 9 + src/models/event/index.js | 2 +- src/models/event/setting.js | 18 ++ src/models/ticket.js | 4 + src/repository/coupon.js | 2 + src/repository/speaker.js | 2 + src/repository/ticket.js | 15 +- src/repository/user.js | 2 + src/routes/event.routes.js | 32 +++- src/routes/index.routes.js | 2 + src/routes/webhook.routes.js | 9 + src/services/event/ticket.js | 160 +++++++++++++++++- src/services/payhere/index.js | 38 +++++ src/services/payhere/util.js | 28 +++ src/services/webhook.js | 12 ++ src/utils/crypto.js | 3 + src/utils/index.js | 1 + src/validations/auth.js | 2 +- src/validations/event.js | 22 ++- 25 files changed, 405 insertions(+), 22 deletions(-) create mode 100644 src/controllers/webhook.js create mode 100644 src/database/migrations/20231120162415-change_event_capacity_to_seats.js create mode 100644 src/routes/webhook.routes.js create mode 100644 src/services/payhere/index.js create mode 100644 src/services/payhere/util.js create mode 100644 src/services/webhook.js create mode 100644 src/utils/crypto.js diff --git a/.env.example b/.env.example index 9c149c1..f0843fc 100644 --- a/.env.example +++ b/.env.example @@ -24,4 +24,9 @@ SCOREKEEPER_REPO_OWNER= SCOREKEEPER_REPO_NAME= AZURE_CHALLENGE_UPLOAD_SAS_TOKEN= -AZURE_SOLUTION_DOWNLOAD_SAS_TOKEN= \ No newline at end of file +AZURE_SOLUTION_DOWNLOAD_SAS_TOKEN= + +PAYHERE_BASE_URL= +PAYHERE_MERCHANT_SECRET= +PAYHERE_MERCHANT_ID= +PAYHERE_AUTHORIZATION_CODE= \ No newline at end of file diff --git a/package.json b/package.json index 5d49c75..5bfdba7 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@sliit-foss/functions": "2.2.4", "@sliit-foss/http-logger": "1.1.2", "@sliit-foss/module-logger": "1.1.5", - "@sliit-foss/service-connector": "1.2.4", + "@sliit-foss/service-connector": "2.0.0", "bcryptjs": "2.4.3", "celebrate": "15.0.1", "compression": "1.7.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8758f15..eb0e5a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: 1.1.5 version: 1.1.5 '@sliit-foss/service-connector': - specifier: 1.2.4 - version: 1.2.4 + specifier: 2.0.0 + version: 2.0.0 bcryptjs: specifier: 2.4.3 version: 2.4.3 @@ -2222,8 +2222,8 @@ packages: winston-daily-rotate-file: 4.7.1(winston@3.8.2) dev: false - /@sliit-foss/service-connector@1.2.4: - resolution: {integrity: sha512-oPfJp6CkDsn8y+LnQnedrZhDhrC+AKnOQpq1jrEAbdUGXd8KBscPhmqeGdtAld+qGVIeKNpsxvNFMSPhED6uqQ==} + /@sliit-foss/service-connector@2.0.0: + resolution: {integrity: sha512-N2ZQIu2bNnQx2bkANC/XYVSYk+8q0mT/Q+hbFfzWxPlNmuuTQyTO8wWBKR7mj+KoiP/VVa5kOxGCYQvO6MVCCg==} dependencies: '@sliit-foss/module-logger': 1.1.5 axios: 1.3.2 diff --git a/src/config/index.js b/src/config/index.js index f9b5fe4..649012f 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -25,6 +25,13 @@ export const AZURE = { SOLUTION_DOWNLOAD_SAS_TOKEN: process.env.AZURE_SOLUTION_DOWNLOAD_SAS_TOKEN }; +export const PAYHERE = { + BASE_URL: process.env.PAYHERE_BASE_URL, + MERCHANT_ID: process.env.PAYHERE_MERCHANT_ID, + MERCHANT_SECRET: process.env.PAYHERE_MERCHANT_SECRET, + AUTHORIZATION_CODE: process.env.PAYHERE_AUTHORIZATION_CODE +}; + export const PORT = process.env.PORT || 3000; export const APP_ENV = process.env.APP_ENV; @@ -44,6 +51,7 @@ export default { SCOREKEEPER, JWT, AZURE, + PAYHERE, PORT, APP_ENV, MONGO_URI, diff --git a/src/controllers/event/ticket.js b/src/controllers/event/ticket.js index 879eb33..5eb28ef 100644 --- a/src/controllers/event/ticket.js +++ b/src/controllers/event/ticket.js @@ -1,3 +1,4 @@ +import { default as createError } from 'http-errors'; import * as eventService from '@/services/event'; import { makeResponse } from '@/utils/response'; @@ -15,3 +16,26 @@ export const approveUserTicket = async (req, res) => { await eventService.approveTicket(req.params.ticket_id, req.user); return makeResponse({ res, message: 'User ticket approved successfully' }); }; + +export const initiateTicketPayment = async (req, res) => { + const ticket = await eventService.initiateTicketPayment(req.params.ticket_id, req.body.coupon_code, req.user); + return makeResponse({ res, data: ticket, message: 'Ticket payment initiated successfully' }); +}; + +export const verifyTicketPayment = async (req, res) => { + const ticket = await eventService.verifyTicketPayment(req.params.ticket_id, req.user); + return makeResponse({ res, data: ticket, message: 'Ticket payment verified successfully' }); +}; + +export const cancelTicketPayment = async (req, res) => { + await eventService.cancelTicketPayment(req.params.ticket_id, req.user); + return makeResponse({ res, message: 'Ticket payment cancelled successfully' }); +}; + +export const transferTicket = async (req, res) => { + if (req.body.email === req.user.email) { + throw new createError(400, 'You cannot transfer your ticket to yourself'); + } + await eventService.transferTicket(req.params.ticket_id, req.body.email, req.user); + return makeResponse({ res, message: 'Ticket transferred successfully' }); +}; diff --git a/src/controllers/webhook.js b/src/controllers/webhook.js new file mode 100644 index 0000000..2d7aa1f --- /dev/null +++ b/src/controllers/webhook.js @@ -0,0 +1,15 @@ +import { default as createError } from 'http-errors'; +import { webhookSignature } from '@/services/payhere/util'; +import * as webhookService from '@/services/webhook'; +import { makeResponse } from '@/utils/response'; + +export const handlePayment = async (req, res) => { + if ( + req.body?.md5sig !== + webhookSignature(req.body.order_id, req.body.payhere_amount, req.body.payhere_currency, req.body.status_code) + ) { + throw new createError(401, 'Invalid signature'); + } + await webhookService.paymentStatusHandler(req.body); + return makeResponse({ res, message: 'Webhook received successfully' }); +}; diff --git a/src/database/migrations/20231120162415-change_event_capacity_to_seats.js b/src/database/migrations/20231120162415-change_event_capacity_to_seats.js new file mode 100644 index 0000000..0261135 --- /dev/null +++ b/src/database/migrations/20231120162415-change_event_capacity_to_seats.js @@ -0,0 +1,9 @@ +module.exports = { + async up(db) { + await db.collection('events').updateMany({}, { $rename: { capacity: 'seats' } }); + }, + + async down(db) { + await db.collection('events').updateMany({}, { $rename: { seats: 'capacity' } }); + } +}; diff --git a/src/models/event/index.js b/src/models/event/index.js index 538b3ac..f097e5c 100644 --- a/src/models/event/index.js +++ b/src/models/event/index.js @@ -14,7 +14,7 @@ const EventSchema = new mongoose.Schema( type: String, required: true }, - capacity: { + seats: { type: Number, required: true }, diff --git a/src/models/event/setting.js b/src/models/event/setting.js index 7c6fd85..01bec7b 100644 --- a/src/models/event/setting.js +++ b/src/models/event/setting.js @@ -31,6 +31,20 @@ const PaymentSettingsSchema = new mongoose.Schema({ type: Number, default: 0 } + }, + premium_tickets: { + enabled: { + type: Boolean, + default: false + }, + cost: { + type: Number, + default: 0 + }, + seats: { + type: Number, + default: 0 + } } }); @@ -52,6 +66,10 @@ const SettingSchema = new mongoose.Schema({ type: Boolean, default: true }, + ticket_transfer_enabled: { + type: Boolean, + default: false + }, registration_start: { type: Date, required: true diff --git a/src/models/ticket.js b/src/models/ticket.js index 2c8703a..f4af55b 100644 --- a/src/models/ticket.js +++ b/src/models/ticket.js @@ -76,6 +76,10 @@ const TicketSchema = new mongoose.Schema( utilized: { type: Boolean, default: false + }, + premium: { + type: Boolean, + default: false } }, { diff --git a/src/repository/coupon.js b/src/repository/coupon.js index fe60114..b2c804b 100644 --- a/src/repository/coupon.js +++ b/src/repository/coupon.js @@ -15,6 +15,8 @@ export const findAll = ({ sort = {}, filter = {}, page, limit = 10 }) => { export const findById = (id) => Coupon.findById(id).lean(); +export const findByCode = (code) => Coupon.findOne({ code }).populate('ticket').lean(); + export const findOne = (filters, options = {}) => Coupon.findOne(filters, options).lean(); export const findOneAndUpdate = (filters, data) => Coupon.findOneAndUpdate(filters, data, { new: true }).lean(); diff --git a/src/repository/speaker.js b/src/repository/speaker.js index 8b62f6e..06c6763 100644 --- a/src/repository/speaker.js +++ b/src/repository/speaker.js @@ -17,6 +17,8 @@ export const findAll = ({ sort = {}, filter = {}, page, limit = 10 }) => { export const findById = (id) => Speaker.findById(id).lean(); +export const findByEmail = (email) => Speaker.findOne({ email }).lean(); + export const findOne = (filters, options = {}) => Speaker.findOne(filters, options).lean(); export const findOneAndUpdate = (filters, data) => Speaker.findOneAndUpdate(filters, data, { new: true }).lean(); diff --git a/src/repository/ticket.js b/src/repository/ticket.js index bd3a7f2..5bfa552 100644 --- a/src/repository/ticket.js +++ b/src/repository/ticket.js @@ -1,3 +1,4 @@ +import createError from 'http-errors'; import { omit } from 'lodash'; import mongoose from 'mongoose'; import Ticket, { payments } from '@/models/ticket'; @@ -21,7 +22,12 @@ export const findOne = (filters, filterFields = false) => { return query.exec(); }; -export const findById = (id) => Ticket.findById(id).lean(); +export const findById = async (id, throwError = false) => { + const ticket = await Ticket.findById(id).lean(); + if (!ticket && throwError) throw new createError(404, "Ticket doesn't exist"); +}; + +export const findByReference = (reference) => Ticket.findOne({ reference }).lean(); export const findWithApprovedUser = (id) => Ticket.findById(id).populate('approved_by').lean(); @@ -31,6 +37,12 @@ export const updateById = (id, data) => findOneAndUpdate({ _id: id }, data); export const deleteById = (id) => Ticket.deleteOne({ _id: id }); +export const getPremiumTicketCount = (eventId) => + Ticket.countDocuments({ event: eventId, premium: true, payment_status: payments.success }).lean(); + +export const getPaidTicketCount = (eventId) => + Ticket.countDocuments({ event: eventId, payment_status: payments.success }).lean(); + export const getTicketStats = async (eventId) => { const pipeline = [ { @@ -40,6 +52,7 @@ export const getTicketStats = async (eventId) => { approved_count: { $sum: { $cond: [{ $eq: ['$approved', true] }, 1, 0] } }, transferred_count: { $sum: { $cond: [{ $eq: ['$transferred', true] }, 1, 0] } }, utilized_count: { $sum: { $cond: [{ $eq: ['$utilized', true] }, 1, 0] } }, + premium_count: { $sum: { $cond: [{ $eq: ['$premium', true] }, 1, 0] } }, unpaid: { $sum: { $cond: [{ $and: [{ $eq: ['$approved', true] }, { $eq: ['$payment_status', payments.pending] }] }, 1, 0] diff --git a/src/repository/user.js b/src/repository/user.js index 4909eb9..7217e42 100644 --- a/src/repository/user.js +++ b/src/repository/user.js @@ -62,6 +62,8 @@ export const findOne = async (filters, returnPassword = false) => { return user; }; +export const findByEmail = (email) => User.findOne({ email }).lean(); + export const findOneAndUpdate = async (filters, data) => { const user = await User.findOneAndUpdate(filters, dot(data), { new: true }).lean(); if (!user) return null; diff --git a/src/routes/event.routes.js b/src/routes/event.routes.js index 3237324..626df11 100644 --- a/src/routes/event.routes.js +++ b/src/routes/event.routes.js @@ -3,20 +3,26 @@ import { tracedAsyncHandler } from '@sliit-foss/functions'; import { Segments, celebrate } from 'celebrate'; import { approveUserTicket, + cancelTicketPayment, createEvent, deleteEvent, getAllEvents, getEventById, getUserEventTicket, + initiateTicketPayment, requestEventTicket, - updateEvent + transferTicket, + updateEvent, + verifyTicketPayment } from '@/controllers/event'; import { adminProtect, identify, protect } from '@/middleware/auth'; import { addEventSchema, eventIdSchema, eventTicketIdSchema, + initiateTicketPaymentSchema, requestTicketSchema, + transferTicketSchema, updateEventSchema } from '@/validations/event'; @@ -64,5 +70,29 @@ events.patch( celebrate({ [Segments.PARAMS]: eventTicketIdSchema }), tracedAsyncHandler(approveUserTicket) ); +events.post( + '/:event_id/tickets/:ticket_id/payment', + protect, + celebrate({ [Segments.PARAMS]: eventTicketIdSchema, [Segments.BODY]: initiateTicketPaymentSchema }), + tracedAsyncHandler(initiateTicketPayment) +); +events.get( + '/:event_id/tickets/:ticket_id/payment/verify', + protect, + celebrate({ [Segments.PARAMS]: eventTicketIdSchema }), + tracedAsyncHandler(verifyTicketPayment) +); +events.patch( + '/:event_id/tickets/:ticket_id/payment/cancel', + protect, + celebrate({ [Segments.PARAMS]: eventTicketIdSchema }), + tracedAsyncHandler(cancelTicketPayment) +); +events.patch( + '/:event_id/tickets/:ticket_id/transfer', + protect, + celebrate({ [Segments.PARAMS]: eventTicketIdSchema, [Segments.BODY]: transferTicketSchema }), + tracedAsyncHandler(transferTicket) +); export default events; diff --git a/src/routes/index.routes.js b/src/routes/index.routes.js index 6faa5a7..8e776dc 100644 --- a/src/routes/index.routes.js +++ b/src/routes/index.routes.js @@ -11,6 +11,7 @@ import speakerRouter from './speaker.routes'; import storageRouter from './storage.routes'; import submissionRouter from './submission.routes'; import userRouter from './user.routes'; +import webhookRouter from './webhook.routes'; const router = express.Router(); @@ -25,5 +26,6 @@ router.use('/leaderboard', leaderboardRouter); router.use('/settings', protect, settingRouter); router.use('/speakers', protect, adminProtect, speakerRouter); router.use('/storage', protect, adminProtect, storageRouter); +router.use('/webhooks', webhookRouter); export default router; diff --git a/src/routes/webhook.routes.js b/src/routes/webhook.routes.js new file mode 100644 index 0000000..5110ad0 --- /dev/null +++ b/src/routes/webhook.routes.js @@ -0,0 +1,9 @@ +import express from 'express'; +import { tracedAsyncHandler } from '@sliit-foss/functions'; +import { handlePayment } from '@/controllers/webhook'; + +const webhooks = express.Router(); + +webhooks.post('/payments', tracedAsyncHandler(handlePayment)); + +export default webhooks; diff --git a/src/services/event/ticket.js b/src/services/event/ticket.js index 5af4633..40b6f0a 100644 --- a/src/services/event/ticket.js +++ b/src/services/event/ticket.js @@ -1,26 +1,48 @@ import crypto from 'crypto'; import { default as createError } from 'http-errors'; +import { PAYHERE } from '@/config'; +import { isProduction } from '@/constants'; +import { payments } from '@/models/ticket'; +import * as couponRepository from '@/repository/coupon'; +import * as speakerRepository from '@/repository/speaker'; import * as ticketRepository from '@/repository/ticket'; +import * as userRepository from '@/repository/user'; +import * as payhere from '@/services/payhere'; import { retrieve } from '.'; export const requestTicket = async (event_id, data, user) => { const event = await retrieve(event_id, user); - if (data.survey_answers.length !== event.survey.length) { - throw new createError(400, 'All survey questions must be answered'); - } if ( new Date() < new Date(event.settings.registration_start) || new Date() > new Date(event.settings.registration_end) ) { throw new createError(400, 'Registration for this event is not open at the moment'); } - let approved = false; - if (event.settings.automatic_approval) { - approved = true; + if (data.premium) { + if (event.settings.payments?.premium_tickets?.enabled) { + const premiumTicketCount = await ticketRepository.getPremiumTicketCount(event_id); + if (premiumTicketCount >= event.settings.payments.premium_tickets.seats) { + throw new createError(400, 'Premium tickets are sold out'); + } + } + throw new createError(400, 'Premium tickets are not available for this event'); + } else { + if (data.survey_answers.length !== event.survey.length) { + throw new createError(400, 'All survey questions must be answered'); + } } + const approved = event.settings.automatic_approval || data.premium; const reference = crypto.randomUUID(); const survey = data.survey_answers.map((answer, index) => ({ question: event.survey[index], answer })); - return ticketRepository.insertOne({ cost: 0, event: event_id, owner: user._id, approved, survey, reference }); + return ticketRepository.insertOne({ + cost: 0, + event: event_id, + owner: user._id, + approved, + survey, + reference, + premium: data.premium + }); }; export const getUserTicket = async (user) => { @@ -30,8 +52,128 @@ export const getUserTicket = async (user) => { }; export const approveTicket = async (ticket_id, user) => { - const ticket = await ticketRepository.findById(ticket_id); - if (!ticket) throw new createError(404, "Ticket doesn't exist"); + const ticket = await ticketRepository.findById(ticket_id, true); if (ticket.approved) throw new createError(400, `This ticket is already approved`); return ticketRepository.updateById(ticket_id, { approved: true, approved_by: user._id }); }; + +export const initiateTicketPayment = async (ticket_id, coupon_code, user) => { + const [ticket, speaker] = await Promise.all([ + ticketRepository.findById(ticket_id, true), + speakerRepository.findByEmail(user.email) + ]); + + if (ticket.owner.toString() !== user._id.toString()) throw new createError(403, 'Ticket does not belong to you'); + + if (ticket.payment_status === payments.success) return ticket; + + const event = await retrieve(ticket.event, user); + + const promises = [ticketRepository.getPaidTicketCount(ticket.event)]; + + if (ticket.premium) promises.push(ticketRepository.getPremiumTicketCount(ticket.event)); + + const [purchasedTicketCount, premiumTicketCount] = await Promise.all(promises); + + if (purchasedTicketCount >= event.seats) { + throw new createError(400, 'House full'); + } + + if (ticket.premium) { + if (premiumTicketCount >= event.settings.payments?.premium_tickets?.seats) { + throw new createError(400, 'Premium tickets are sold out'); + } + } + + let cost = ticket.premium ? event.settings.payments?.premium_tickets?.cost : event.settings.payments?.ticket_cost; + + if (speaker && event.settings.payments?.speaker_discount_percentage) { + cost -= (cost * event.settings.payments?.speaker_discount_percentage) / 100; + } + + let coupon = null; + + if (coupon_code) { + if (!event.settings.payments?.allow_coupons) { + throw new createError(400, 'Coupons are not allowed for this event'); + } + coupon = await couponRepository.findByCode(coupon_code); + if (!coupon) { + throw new createError(400, 'Invalid coupon code'); + } + if (coupon.ticket && coupon.ticket.payment_status === payments.success) { + throw new createError(400, 'Coupon code is already used'); + } + cost -= (cost * coupon.discount_percentage) / 100; + } + + if (event.settings.payments?.early_bird_discount?.enabled) { + if (new Date() < new Date(event.settings.payments.early_bird_discount.deadline)) { + cost -= (cost * event.settings.payments.early_bird_discount.percentage) / 100; + } + } + + if (coupon) couponRepository.updateById(coupon._id, { ticket: ticket._id }); + + if (cost === 0) { + return ticketRepository.updateById(ticket._id, { cost: 0, payment_status: payments.success }); + } + + const updatedTicket = await ticketRepository.updateById(ticket._id, { cost, payment_status: payments.pending }); + + return { + ...updatedTicket, + payhere_config: { + hash: payhere.generateHash(ticket.reference, cost), + merchant_id: PAYHERE.MERCHANT_ID, + is_production: isProduction() + } + }; +}; + +export const verifyTicketPayment = async (ticket_id, user) => { + const ticket = await ticketRepository.findById(ticket_id, true); + if (ticket.owner.toString() !== user._id.toString()) throw new createError(403, 'Ticket does not belong to you'); + if (ticket.payment_status === payments.success) return ticket; + const res = await payhere.retrievePaymentDetails(ticket.reference); + let paymentStatus = payments.failed; + if (res.status === 1) { + paymentStatus = payhere.statusMap[res.data?.[0]?.status] || payments.failed; + } + return ticketRepository.updateById(ticket_id, { payment_status: paymentStatus }); +}; + +export const cancelTicketPayment = async (ticket_id, user) => { + const ticket = await ticketRepository.findById(ticket_id, true); + if (ticket.owner.toString() !== user._id.toString()) throw new createError(403, 'Ticket does not belong to you'); + if ([!payments.success, payments.refunded, payments.chargebacked].includes(ticket.payment_status)) { + return ticketRepository.updateById(ticket_id, { payment_status: payments.cancelled }); + } + return ticket; +}; + +export const transferTicket = async (ticket_id, email, user) => { + const [ticket, transferrableUser] = await Promise.all([ + ticketRepository.findById(ticket_id, true), + userRepository.findByEmail(email) + ]); + + if (ticket.owner.toString() !== user._id.toString()) throw new createError(403, 'Ticket does not belong to you'); + + if (!transferrableUser) throw new createError(400, 'User to transfer the ticket to is not registered'); + + const event = await retrieve(ticket.event, user); + + if (!ticket.premium && !event.settings.ticket_transfer_enabled) { + throw new createError(400, 'Ticket transfer is not allowed for this event'); + } + + const existingTicket = await ticketRepository.findOne({ owner: transferrableUser._id, event: ticket.event }); + if (existingTicket && existingTicket.payment_status === payments.success) { + throw new createError( + 400, + 'User to transfer the ticket to already has a ticket for which the payment is successful' + ); + } + return ticketRepository.updateById(ticket_id, { owner: transferrableUser._id, transferred: true }); +}; diff --git a/src/services/payhere/index.js b/src/services/payhere/index.js new file mode 100644 index 0000000..168b835 --- /dev/null +++ b/src/services/payhere/index.js @@ -0,0 +1,38 @@ +import serviceConnector from '@sliit-foss/service-connector'; +import { PAYHERE } from '@/config'; + +export * from './util'; + +const tokenUrlPath = `/merchant/v1/oauth/token`; + +const connector = serviceConnector({ + baseURL: PAYHERE.BASE_URL, + service: 'Payhere', + headerIntercepts: async ({ url }) => { + if (url !== tokenUrlPath) { + const accessToken = await getAccessToken(); + return { + Authorization: `Bearer ${accessToken}` + }; + } + } +}); + +const getAccessToken = () => { + return connector + .post( + tokenUrlPath, + { grant_type: 'client_credentials' }, + { + headers: { + 'Authorization': `Basic ${PAYHERE.AUTHORIZATION_CODE}`, + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ) + .then((res) => res.data.access_token); +}; + +export const retrievePaymentDetails = (reference) => { + return connector.get(`/merchant/v1/payment/search?order_id=${reference}`).then((res) => res.data); +}; diff --git a/src/services/payhere/util.js b/src/services/payhere/util.js new file mode 100644 index 0000000..584b7fb --- /dev/null +++ b/src/services/payhere/util.js @@ -0,0 +1,28 @@ +import { PAYHERE } from '@/config'; +import { payments } from '@/models/ticket'; +import { md5 } from '@/utils'; + +export const generateHash = (reference, amount, currency = 'LKR') => { + return md5( + PAYHERE.MERCHANT_ID + reference + amount.toFixed(2) + currency + md5(PAYHERE.MERCHANT_SECRET).toUpperCase() + ).toUpperCase(); +}; + +export const webhookSignature = (orderId, amount, currency, statusCode) => + md5( + PAYHERE.MERCHANT_ID + orderId + amount + currency + statusCode + md5(PAYHERE.MERCHANT_SECRET).toUpperCase() + ).toUpperCase(); + +export const statusMap = { + RECEIVED: payments.success, + REFUNDED: payments.refunded, + CHARGEBACKED: payments.chargebacked +}; + +export const statusCodeMap = { + '2': payments.success, + '0': payments.pending, + '-1': payments.cancelled, + '-2': payments.failed, + '-3': payments.chargebacked +}; diff --git a/src/services/webhook.js b/src/services/webhook.js new file mode 100644 index 0000000..4c7ff57 --- /dev/null +++ b/src/services/webhook.js @@ -0,0 +1,12 @@ +import createError from 'http-errors'; +import { payments } from '@/models/ticket'; +import * as ticketRepository from '@/repository/ticket'; +import { statusCodeMap } from './payhere/util'; + +export const paymentStatusHandler = async (payload) => { + const ticket = await ticketRepository.findByReference(payload.order_id); + if (ticket && ticket.payment_status !== payments.success) { + return ticketRepository.updateById(ticket._id, { payment_status: statusCodeMap[payload.status_code.toString()] }); + } + throw new createError(404, 'Ticket not found'); +}; diff --git a/src/utils/crypto.js b/src/utils/crypto.js new file mode 100644 index 0000000..751031d --- /dev/null +++ b/src/utils/crypto.js @@ -0,0 +1,3 @@ +import crypto from 'crypto'; + +export const md5 = (data) => crypto.createHash('md5').update(data).digest('hex'); diff --git a/src/utils/index.js b/src/utils/index.js index 41f5189..0b7dc1c 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,5 +1,6 @@ import context from 'express-http-context'; +export * from './crypto'; export * from './json'; export * from './jwt'; export * from './repository'; diff --git a/src/validations/auth.js b/src/validations/auth.js index 0d685c2..a7bec3b 100644 --- a/src/validations/auth.js +++ b/src/validations/auth.js @@ -42,7 +42,7 @@ export const registerSchema = Joi.object({ .required(), tshirt_size: Joi.string() .valid(...tshirtSizes) - .required() + .optional() }); export const resendVerifyMailSchema = Joi.object({ diff --git a/src/validations/event.js b/src/validations/event.js index 9dd154b..2570949 100644 --- a/src/validations/event.js +++ b/src/validations/event.js @@ -25,6 +25,7 @@ const optionals = { settings: Joi.object({ enabled: Joi.boolean().optional(), automatic_approval: Joi.boolean().optional(), + ticket_transfer_enabled: Joi.boolean().optional(), registration_start: Joi.date().required(), registration_end: Joi.date().required(), payments: Joi.object({ @@ -36,6 +37,11 @@ const optionals = { enabled: Joi.boolean().optional(), deadline: Joi.date().optional(), percentage: Joi.number().optional() + }).optional(), + premium_tickets: Joi.object({ + enabled: Joi.boolean().optional(), + cost: Joi.number().optional(), + seats: Joi.number().optional() }).optional() }).optional(), visuals: Joi.object({ @@ -48,7 +54,7 @@ export const addEventSchema = { ...optionals, name: Joi.string().required(), description: Joi.string().required(), - capacity: Joi.number().required(), + seats: Joi.number().required(), event_date: Joi.date().required().greater('now'), tags: optionals.tags.required(), settings: optionals.settings.required() @@ -58,7 +64,7 @@ export const updateEventSchema = { ...optionals, name: Joi.string().optional(), description: Joi.string().optional(), - capacity: Joi.number().optional(), + seats: Joi.number().optional(), event_date: Joi.date().optional(), settings: optionals.settings .concat( @@ -80,5 +86,15 @@ export const eventTicketIdSchema = { }; export const requestTicketSchema = { - survey_answers: Joi.array().items(Joi.string()).default([]) + survey_answers: Joi.array().items(Joi.string()).default([]), + premium: Joi.boolean().default(false) +}; + +export const initiateTicketPaymentSchema = { + coupon_code: Joi.string().optional(), + merchandise: Joi.array().items(Joi.string().hex().length(24)).optional().default([]) +}; + +export const transferTicketSchema = { + email: Joi.string().email().required() };