diff --git a/src/modules/user/handlers/payment/verifyPayment.ts b/src/modules/user/handlers/payment/verifyPayment.ts index 9a12117..7332c74 100644 --- a/src/modules/user/handlers/payment/verifyPayment.ts +++ b/src/modules/user/handlers/payment/verifyPayment.ts @@ -64,7 +64,7 @@ export const handler = safeHandler(async ( }, body: JSON.stringify({ success: true, - message: 'Payment verified', + message: 'Payment verified and itinerary booked successfully', data: result, }), }; diff --git a/src/modules/user/services/itinerary.service.ts b/src/modules/user/services/itinerary.service.ts index dabcfa9..5f609d3 100644 --- a/src/modules/user/services/itinerary.service.ts +++ b/src/modules/user/services/itinerary.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Prisma, PrismaClient } from '@prisma/client'; +import crypto from 'crypto'; import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl'; import ApiError from '../../../common/utils/helper/ApiError'; import { @@ -30,6 +31,8 @@ const attachPresignedUrl = async (file: string | null | undefined) => { return getPresignedUrl(bucket, key); }; +const generateCheckInCode = () => `#${crypto.randomInt(1000000, 10000000)}`; + type CheckoutTaxRow = { id: number; taxName: string; @@ -2391,6 +2394,393 @@ export class ItineraryService { }; } + async bookItineraryAfterPayment( + tx: Prisma.TransactionClient, + userXid: number, + itineraryHeaderXid: number, + ) { + const itinerary = await tx.itineraryHeader.findFirst({ + where: { + id: itineraryHeaderXid, + ownerXid: userXid, + isActive: true, + deletedAt: null, + }, + select: { + id: true, + itineraryNo: true, + title: true, + ownerXid: true, + fromDate: true, + toDate: true, + ItineraryMembers: { + where: { + memberXid: userXid, + isActive: true, + deletedAt: null, + }, + select: { + id: true, + memberXid: true, + memberRole: true, + memberStatus: true, + }, + }, + ItineraryActivities: { + where: { + isActive: true, + deletedAt: null, + }, + orderBy: [ + { displayOrder: 'asc' }, + { createdAt: 'asc' }, + ], + select: { + id: true, + displayOrder: true, + itineraryType: true, + activityXid: true, + scheduledHeaderXid: true, + occurenceDate: true, + startTime: true, + endTime: true, + paxCount: true, + totalAmount: true, + bookingStatus: true, + activity: { + select: { + id: true, + activityTitle: true, + }, + }, + scheduledHeader: { + select: { + id: true, + startDate: true, + endDate: true, + activity: { + select: { + activityDurationMins: true, + }, + }, + ScheduleDetails: { + where: { + isActive: true, + deletedAt: null, + }, + select: { + id: true, + occurenceDate: true, + weekDay: true, + dayOfMonth: true, + startTime: true, + endTime: true, + maxCapacity: true, + }, + }, + Cancellations: { + where: { + isActive: true, + deletedAt: null, + }, + select: { + occurenceDate: true, + startTime: true, + endTime: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!itinerary) { + throw new ApiError(404, 'Itinerary not found for booking.'); + } + + const itineraryMember = itinerary.ItineraryMembers[0] ?? null; + if (!itineraryMember) { + throw new ApiError(404, 'Itinerary member record not found for booking.'); + } + + const bookedActivities = await Promise.all( + itinerary.ItineraryActivities.map(async (activity) => { + if ( + activity.itineraryType !== 'ACTIVITY' || + !activity.activityXid || + !activity.scheduledHeaderXid || + !activity.scheduledHeader + ) { + return { + itineraryActivityXid: activity.id, + skipped: true, + reason: 'not_bookable_activity', + }; + } + + const existingDetail = await tx.itineraryDetails.findFirst({ + where: { + itineraryActivityXid: activity.id, + itineraryMemberXid: itineraryMember.id, + itineraryKind: 'ACTIVITY', + isActive: true, + deletedAt: null, + }, + select: { + id: true, + offlineCode: true, + }, + }); + + if (existingDetail) { + return { + itineraryActivityXid: activity.id, + skipped: false, + alreadyBooked: true, + checkInCode: existingDetail.offlineCode, + remainingCapacity: null, + }; + } + + const scheduleRangeStart = + activity.scheduledHeader.startDate > itinerary.fromDate + ? activity.scheduledHeader.startDate + : itinerary.fromDate; + const scheduleRangeEnd = + (activity.scheduledHeader.endDate ?? itinerary.toDate) < itinerary.toDate + ? (activity.scheduledHeader.endDate ?? itinerary.toDate) + : itinerary.toDate; + + const cancelledSlots = new Set( + activity.scheduledHeader.Cancellations.map((cancellation) => { + if (!cancellation.occurenceDate) { + return null; + } + + return `${formatDateKey(cancellation.occurenceDate)}|${cancellation.startTime}|${cancellation.endTime}`; + }).filter(Boolean) as string[], + ); + + const requestedOccurrenceDate = formatDateKey(activity.occurenceDate); + const requestedStartTime = normalizeTimeValue(activity.startTime); + const requestedEndTime = normalizeTimeValue(activity.endTime); + + const expandedSlots = activity.scheduledHeader.ScheduleDetails.flatMap((slot) => + getUniqueDatesForScheduleDetail( + { + occurenceDate: slot.occurenceDate, + weekDay: slot.weekDay, + dayOfMonth: slot.dayOfMonth, + }, + scheduleRangeStart, + scheduleRangeEnd, + ).map((slotDate) => { + const slotStart = combineDateAndTime(slotDate, slot.startTime); + const slotEnd = slotStart + ? combineDateAndTime(slotDate, slot.endTime) ?? + (activity.scheduledHeader?.activity?.activityDurationMins + ? addMinutes( + slotStart, + activity.scheduledHeader.activity.activityDurationMins, + ) + : null) + : null; + + const normalizedSlotDate = formatDateKey(slotDate); + const normalizedSlotStartTime = normalizeTimeValue(slot.startTime); + const normalizedSlotEndTime = normalizeTimeValue(slot.endTime); + const cancellationKey = `${normalizedSlotDate}|${slot.startTime}|${slot.endTime}`; + + const mismatchReasons: string[] = []; + + if (!slotStart || !slotEnd) { + mismatchReasons.push('invalid_slot_time'); + } + + if (cancelledSlots.has(cancellationKey)) { + mismatchReasons.push('slot_cancelled'); + } + + if (normalizedSlotDate !== requestedOccurrenceDate) { + mismatchReasons.push('occurrence_date_mismatch'); + } + + if (requestedStartTime && normalizedSlotStartTime !== requestedStartTime) { + mismatchReasons.push('start_time_mismatch'); + } + + if (requestedEndTime && normalizedSlotEndTime !== requestedEndTime) { + mismatchReasons.push('end_time_mismatch'); + } + + return { + slotId: slot.id, + occurenceDate: startOfDay(slotDate), + startTime: slot.startTime, + endTime: slot.endTime, + maxCapacity: slot.maxCapacity, + debug: { + mismatchReasons, + }, + }; + }), + ); + + const selectedSlot = expandedSlots.find( + (slot) => slot.debug.mismatchReasons.length === 0, + ); + + if (!selectedSlot) { + throw new ApiError( + 400, + `Unable to match a valid schedule slot for itinerary activity ${activity.id}.`, + ); + } + + const bookedSeats = Math.max(1, activity.paxCount ?? 1); + if (selectedSlot.maxCapacity < bookedSeats) { + throw new ApiError( + 409, + `Insufficient capacity for itinerary activity ${activity.id}.`, + ); + } + + const capacityUpdate = await tx.scheduleDetails.updateMany({ + where: { + id: selectedSlot.slotId, + maxCapacity: { + gte: bookedSeats, + }, + isActive: true, + deletedAt: null, + }, + data: { + maxCapacity: { + decrement: bookedSeats, + }, + }, + }); + + if (!capacityUpdate.count) { + throw new ApiError( + 409, + `Unable to reserve capacity for itinerary activity ${activity.id}.`, + ); + } + + const refreshedSlot = await tx.scheduleDetails.findUnique({ + where: { + id: selectedSlot.slotId, + }, + select: { + maxCapacity: true, + }, + }); + + let checkInCode = existingDetail?.offlineCode ?? null; + if (!checkInCode) { + for (let attempt = 0; attempt < 5; attempt += 1) { + const candidate = generateCheckInCode(); + const duplicate = await tx.itineraryDetails.findFirst({ + where: { + offlineCode: candidate, + isActive: true, + deletedAt: null, + }, + select: { + id: true, + }, + }); + + if (!duplicate) { + checkInCode = candidate; + break; + } + } + } + + if (!checkInCode) { + throw new ApiError(500, 'Unable to generate a check-in code.'); + } + + const detail = existingDetail + ? await tx.itineraryDetails.update({ + where: { + id: existingDetail.id, + }, + data: { + hasOpted: true, + updatedOn: new Date(), + itineraryKind: 'ACTIVITY', + offlineCode: checkInCode, + activityStatus: 'booked', + isChargeable: Number(activity.totalAmount) > 0, + baseAmount: Number(activity.totalAmount) || 0, + totalAmount: Number(activity.totalAmount) || 0, + itineraryStatus: 'paid', + isPaid: true, + paidByXid: userXid, + paidOn: new Date(), + }, + select: { + id: true, + offlineCode: true, + }, + }) + : await tx.itineraryDetails.create({ + data: { + itineraryActivityXid: activity.id, + itineraryMemberXid: itineraryMember.id, + hasOpted: true, + updatedOn: new Date(), + itineraryKind: 'ACTIVITY', + offlineCode: checkInCode, + activityStatus: 'booked', + isChargeable: Number(activity.totalAmount) > 0, + baseAmount: Number(activity.totalAmount) || 0, + totalAmount: Number(activity.totalAmount) || 0, + itineraryStatus: 'paid', + isPaid: true, + paidByXid: userXid, + paidOn: new Date(), + isActive: true, + }, + select: { + id: true, + offlineCode: true, + }, + }); + + await tx.itineraryActivities.update({ + where: { + id: activity.id, + }, + data: { + bookingStatus: 'booked', + }, + }); + + return { + itineraryActivityXid: activity.id, + skipped: false, + alreadyBooked: false, + checkInCode: detail.offlineCode, + bookedSeats, + remainingCapacity: refreshedSlot?.maxCapacity ?? null, + }; + }), + ); + + return { + itineraryHeaderXid: itinerary.id, + itineraryNo: itinerary.itineraryNo, + itineraryTitle: itinerary.title, + bookedActivities, + }; + } + async saveItineraryActivitySelections( userXid: number, payload: Array<{ diff --git a/src/modules/user/services/payment.service.ts b/src/modules/user/services/payment.service.ts index 2d23c05..6f8c662 100644 --- a/src/modules/user/services/payment.service.ts +++ b/src/modules/user/services/payment.service.ts @@ -5,6 +5,7 @@ import Razorpay from 'razorpay'; import ApiError from '../../../common/utils/helper/ApiError'; import config from '../../../config/config'; +import { ItineraryService } from './itinerary.service'; const razorpay = new Razorpay({ key_id: config.RAZORPAY_KEY_ID, @@ -158,45 +159,65 @@ export class PaymentService { throw new ApiError(400, 'Invalid signature.'); } - const paymentOrder = await this.prisma.paymentOrders.findFirst({ - where: { - razorpayOrderId: orderId, - userXid, - isActive: true, - deletedAt: null, - }, - }); + const itineraryService = new ItineraryService(this.prisma); - if (!paymentOrder) { - throw new ApiError(404, 'Payment order not found.'); - } + const { updatedPaymentOrder, bookingResult } = await this.prisma.$transaction( + async (tx) => { + const paymentOrder = await tx.paymentOrders.findFirst({ + where: { + razorpayOrderId: orderId, + userXid, + isActive: true, + deletedAt: null, + }, + }); - if ( - paymentOrder.paymentStatus === 'paid' && - paymentOrder.razorpayPaymentId === paymentId - ) { - return { - paymentOrderId: paymentOrder.id, - orderId: paymentOrder.razorpayOrderId, - paymentId: paymentOrder.razorpayPaymentId, - status: paymentOrder.paymentStatus, - verifiedAt: paymentOrder.verifiedAt, - paidAt: paymentOrder.paidAt, - }; - } + if (!paymentOrder) { + throw new ApiError(404, 'Payment order not found.'); + } - const updatedPaymentOrder = await this.prisma.paymentOrders.update({ - where: { - id: paymentOrder.id, + let updatedPayment = paymentOrder; + + if (paymentOrder.paymentStatus === 'paid') { + if ( + paymentOrder.razorpayPaymentId && + paymentOrder.razorpayPaymentId !== paymentId + ) { + throw new ApiError( + 400, + 'Payment order is already verified with a different payment id.', + ); + } + } else { + updatedPayment = await tx.paymentOrders.update({ + where: { + id: paymentOrder.id, + }, + data: { + razorpayPaymentId: paymentId, + razorpaySignature: signature, + paymentStatus: 'paid', + verifiedAt: new Date(), + paidAt: new Date(), + }, + }); + } + + let booking = null; + if (updatedPayment.itineraryHeaderXid) { + booking = await itineraryService.bookItineraryAfterPayment( + tx, + userXid, + updatedPayment.itineraryHeaderXid, + ); + } + + return { + updatedPaymentOrder: updatedPayment, + bookingResult: booking, + }; }, - data: { - razorpayPaymentId: paymentId, - razorpaySignature: signature, - paymentStatus: 'paid', - verifiedAt: new Date(), - paidAt: new Date(), - }, - }); + ); return { paymentOrderId: updatedPaymentOrder.id, @@ -205,6 +226,7 @@ export class PaymentService { status: updatedPaymentOrder.paymentStatus, verifiedAt: updatedPaymentOrder.verifiedAt, paidAt: updatedPaymentOrder.paidAt, + itineraryBooking: bookingResult, }; }