booking the itinerary after successful payment

This commit is contained in:
2026-04-13 14:00:50 +05:30
parent b47e6271a3
commit c5dcc5b1f0
3 changed files with 448 additions and 36 deletions

View File

@@ -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,
}),
};

View File

@@ -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<{

View File

@@ -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,7 +159,11 @@ export class PaymentService {
throw new ApiError(400, 'Invalid signature.');
}
const paymentOrder = await this.prisma.paymentOrders.findFirst({
const itineraryService = new ItineraryService(this.prisma);
const { updatedPaymentOrder, bookingResult } = await this.prisma.$transaction(
async (tx) => {
const paymentOrder = await tx.paymentOrders.findFirst({
where: {
razorpayOrderId: orderId,
userXid,
@@ -171,21 +176,20 @@ export class PaymentService {
throw new ApiError(404, 'Payment order not found.');
}
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,
};
}
let updatedPayment = paymentOrder;
const updatedPaymentOrder = await this.prisma.paymentOrders.update({
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,
},
@@ -197,6 +201,23 @@ export class PaymentService {
paidAt: new Date(),
},
});
}
let booking = null;
if (updatedPayment.itineraryHeaderXid) {
booking = await itineraryService.bookItineraryAfterPayment(
tx,
userXid,
updatedPayment.itineraryHeaderXid,
);
}
return {
updatedPaymentOrder: updatedPayment,
bookingResult: booking,
};
},
);
return {
paymentOrderId: updatedPaymentOrder.id,
@@ -205,6 +226,7 @@ export class PaymentService {
status: updatedPaymentOrder.paymentStatus,
verifiedAt: updatedPaymentOrder.verifiedAt,
paidAt: updatedPaymentOrder.paidAt,
itineraryBooking: bookingResult,
};
}