Scope: B2B creation → days & content setup → pricing → publish validation → B2C listing → customer booking → payment → confirmation
Codebase: M-M_Trip_Backend · M-M_Trip_Frontend
Author: Saswath Singh
B2B (Admin/Agent) B2C (Customer)
───────────────── ──────────────
1. Create Itinerary (POST /itinerary)
2. Add days + locations
3. Map hotels to itinerary & days
4. Map transfers to itinerary
5. Add activities per day
6. Set pricing table (PUT /pricing-table)
7. Set markup (via Markup module)
8. PATCH /published-status → publish → Itinerary appears on listing page
9. GET /itinerary (browse, filter, search)
10. GET :id/customer-price-details
11. POST /bookings → DRAFT booking
12. POST /payments/create-order
13. Razorpay checkout (frontend)
14. POST /payments/verify → PROCESSING
15. Razorpay webhook → confirms capture
Admin:
16. PUT /bookings/:id/status → CONFIRMED
17. PUT /confirmation-voucher → add voucher
18. POST /confirmation-voucher/send-email → email to customer
POST /v1/itinerary
Guard: AuthGuard('jwt') + PermissionGuard
Permission: Permission.TRIP
Content-Type: multipart/form-data (banner image is optional)
CreateItineraryDto Fields| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | Yes | Trip name |
categoryId |
number | Yes | Validated via checkCategoryIdExists() |
tripType |
number | No | Maps to HotelCategoryEnum (see below) |
description |
string | No | Rich text HTML |
inclusion |
string[] | No | Stored as array |
exclusion |
string[] | No | |
paymentPolicy |
string | No | |
cancellationPolicy |
string | No | |
itineraryLocations |
array | No | City destinations with daysDuration per city |
activityTypesIds |
number[] | No | Tags: adventure, cultural, etc. |
dates |
array | No | {startDate, endDate} — available departure dates |
isTour |
boolean | No | If true, only ONE date range is allowed |
bannerImg |
file | No | JPEG/PNG, max 5 MB |
latitude, longitude, locationName |
string | No | Stored in supplierRelatedInfo JSON field |
budget |
number | No | |
currencyType |
enum | No | INR by default |
itinerary.service.ts:250)1. normalizeTripTypeToCategory(tripType)
→ maps tripType number to HotelCategoryEnum value
→ legacy mode: 1,2 → STANDARD; 3 → PREMIUM
2. validateTourDateLength(isTour, dates.length)
→ throws ERR_MAX_DATE_LENGTH if isTour=true AND dates.length > 1
3. checkCategoryIdExists(categoryId)
→ throws if categoryId not found in TripCatgTypes
4. ensureTripCreationAccess(tenantId)
→ checks subscription feature limit for 'trips'
→ throws ERR_SUBSCRIPTION_LIMIT_REACHED if exceeded
5. Save Itineraries entity
→ token = randomUUID() (used for PDF sharing links)
→ createdBy = userId
→ isPublished = false (always starts unpublished)
→ supplierRelatedInfo = { latitude, longitude, locationName }
6. If itineraryLocations provided:
→ isIndependentItinerary = true
→ itineraryLocationsData() → inserts ItineraryLocation rows
→ generateDaysData() → creates Days rows (one per day in each city)
→ insertDays() → bulk inserts Day rows
7. If activityTypesIds provided:
→ checkActivityTypesExists() validates IDs
→ insertItnActivityTypes() bulk inserts ItnActivityTypes
8. If dates provided:
→ insertItineraryDates() inserts ItineraryDates rows
9. If banner file provided:
→ s3_upload() to S3_FOLDER_BANNER_IMAGE folder
→ stores filename only in DB (not full URL)
→ at response time: prefix with process.env.AWS_S3_URL
10. reduceFeatureRemainingUsage(tenantId, 'trips')
→ decrements subscription trip counter
11. Returns full itinerary details via getItineraryDetails()
tripType → HotelCategoryEnum Mapping// publish-validation.util.ts:normalizeTripTypeToCategory
// Modern mode (default):
// tripType must already be a valid HotelCategoryEnum value
// e.g. tripType = 5 → HotelCategoryEnum.STANDARD = 5
// Legacy mode (options.legacyMode = true):
// 1 or 2 → STANDARD
// 3 → PREMIUM
enum HotelCategoryEnum {
STANDARD = 5,
DELUXE = 6,
PREMIUM = 7,
LUXURY = 8,
}
This value determines which pricing category rows apply to the trip. A trip's tripType sets the default category for pricing. If the trip type changes on an already-published itinerary, pricing rows for the new category must already exist, or the update is blocked (ERR_TRIP_TYPE_PRICING_ROW_REQUIRED).
After the itinerary is created, the admin builds out its content through several separate operations. All are done via the admin dashboard (B2B).
Each itinerary has a set of Days records (one per day of the trip). Days are created automatically when itineraryLocations are provided during creation, or can be managed via the Days module.
Itinerary
└── Day 1 (locationId → city)
└── DayResourceMapping (order: 1) → Hotel
└── DayResourceMapping (order: 2) → Activity
└── DayResourceMapping (order: 3) → Flight
└── Day 2 (locationId → city)
└── DayResourceMapping → Hotel
└── DayResourceMapping → Activity
DayResourceMapping is a polymorphic join table:
hotelId — populated if it's a hotel mappingactivityId — populated if it's an activityflightId — populated if it's a flightorder — controls display order within a dayHotels are mapped at two levels:
Level 1: Itinerary-level (POST /hotel/map-itinerary)
HotelItineraryMapping
├── hotelId
├── itineraryId
├── category (HotelCategoryEnum — which tier this hotel belongs to)
├── cityId
└── isPrimary (set via PATCH /hotel/primary)
Level 2: Day-level (POST /hotel/map-day)
DayResourceMapping
├── hotelId
├── dayId
└── order
The itinerary-level mapping is used for pricing (which category hotels exist).
The day-level mapping is used for itinerary display (which hotel is shown on which day).
Primary hotel rule: For each itinerary + city + category combination, one hotel is designated isPrimary. This is the hotel shown in price listings and used for pricing display in categoryPerPersonPrices.
Transfers are mapped at the itinerary level only (POST /transfer/map-itinerary):
TransferItineraryMapping
├── transferId
├── itineraryId
└── isPrimary (set via PATCH /transfer/primary)
The pricing engine uses transferItnMapping to know which vehicles are available for this itinerary and to filter capacityPrices rows to only those vehicles.
Primary transfer: The first transfer mapped becomes primary automatically (in ensureTransferMappedToItinerary, isPrimary = mappingCount === 0). A primary transfer is required before publishing.
Activities are added per day. Each activity belongs to a Day and the DayResourceMapping links it. Activity categories (catgType) include: Meals, Sightseeing, etc. Sub-categories (subCatgType) further classify them.
PUT /v1/itinerary/:id/pricing-table
Guard: AuthGuard('jwt') + PermissionGuard
Permission: Permission.TRIP
The scenario field in PricingTableUpsertDto determines which data structure is expected:
hotel_transferUsed when the trip includes both hotel stays and vehicle transfers.
Request shape:
{
"scenario": "hotel_transfer",
"rows": [
{
"category": 5,
"plan": "CP",
"extraBedPrice": 4050,
"childWithoutBedPrice": 2100,
"dinnerSupplementPrice": 1950,
"vehiclePricing": [
{ "transferId": 3001, "capacityMin": 1, "capacityMax": 3, "pricePerPerson": 15100 },
{ "transferId": 3001, "capacityMin": 4, "capacityMax": 6, "pricePerPerson": 13200 }
]
},
{
"category": 6,
"plan": "MAP",
"vehiclePricing": [ ... ]
}
]
}
What it stores:
ItineraryBundlePricing row per category (the hotel tier)BundleCapacityPrice row per vehicle slab within that categorycapacityMin / capacityMax define the passenger count slab (e.g. 1–3 pax uses this price)hotel_onlyUsed when the trip includes hotel stays but customer arranges their own transport.
{
"scenario": "hotel_only",
"hotelRows": [
{
"category": 5,
"pricePerPerson": 12000,
"plan": "CP",
"extraBedPrice": 3500,
"childWithoutBedPrice": 1800,
"dinnerSupplementPrice": 1500
}
]
}
What it stores: ItineraryBundlePricing rows with a flat pricePerPerson (no vehicle slabs). The engine uses bundlePricing.category directly.
transfer_onlyUsed for day-trips or activity packages where only transport is provided (no hotel stay).
{
"scenario": "transfer_only",
"category": 5,
"transferRows": [
{ "transferId": 3001, "capacityMin": 1, "capacityMax": 3, "pricePerPerson": 5000 },
{ "transferId": 3001, "capacityMin": 4, "capacityMax": 8, "pricePerPerson": 3500 }
]
}
What it stores: A single ItineraryBundlePricing row with the given category, and BundleCapacityPrice rows as the vehicle slabs.
activities_onlyUsed for activity-based packages with no hotel or transfer.
{
"scenario": "activities_only",
"displayMode": 1,
"activitiesOnly": {
"adultPrice": 5000,
"childPrice": 2000
}
}
What it stores: Sets itinerary.travelerDetails.adultPrice and childPrice (stored as JSON on the Itinerary entity). displayMode controls how prices are shown on the frontend (per-person, total, etc.).
savePricingTable Service Methoditinerary.service.ts → savePricingTable(id, dto, userId)
→ itineraryRepository.upsertBundlePricing(...)
→ For hotel_transfer / hotel_only / transfer_only:
→ upsert ItineraryBundlePricing (one per category)
→ upsert BundleCapacityPrice rows (one per vehicle slab)
→ For activities_only:
→ updates itinerary.travelerDetails JSON field
→ syncItineraryPricePerPersonFromSummary()
→ recalculates pricePerPerson on Itinerary entity
→ calls getItineraryPriceDetails(id, { channel: 'b2b' })
→ stores grandTotal from summary into itinerary.pricePerPerson
pricePerPerson on the Itinerary entity is the cached "starting from" price used in listing pages. It's always the B2B price (no markup). It gets updated after every pricing table save.
savePaxDetails Method (Traveler Details)PUT /v1/itinerary/:id/travelers
Saves the default traveler configuration for the itinerary:
adults, children, infants (default pax counts)selectedCategory (default hotel tier)selectedVehicleSlab (default vehicle choice)extraMattressCountincludeDinnerSupplementdisplayModeThese defaults are used when a customer views the trip without selecting specific options — the price shown is calculated with these defaults.
Markup is applied dynamically at query time — it is never stored on the pricing row. Every time getItineraryPriceDetails is called, the engine fetches the current active markup.
itinerary.repository.ts:693)getApplicableMarkupConfig(itineraryId)
Priority order:
1. Itinerary-specific markup (Markup.isGlobal=false, mapped to this itinerary)
→ picks most recently updated active non-deleted markup for this itinerary
2. Global markup (Markup.isGlobal=true)
→ picks most recently updated active non-deleted global markup for the tenant
3. null → no markup, use base price as-is
itinerary.repository.ts:746)getApplicableGstFromMarkup(itineraryId)
Priority order:
1. Itinerary-specific GST (Markup.category=GST, mapped to this itinerary)
→ takes MAX(amount) when multiple GST records exist for one itinerary
2. Global GST (Markup.isGlobal=true, Markup.category=GST)
3. null → no GST
TCS (Tax Collected at Source) applies only to international itineraries:
// itinerary.repository.ts
const hasInternationalLocation = this.isInternationalItinerary(itinerary);
const tcsPercentage = hasInternationalLocation ? TCS_PERCENTAGE : 0;
// TCS_PERCENTAGE = 2 (from pricing.constants.ts)
An itinerary is international if any of its days' locations, or any mapped hotel's city, has a country ISO code that is not India (IN).
baseCost (from selected bundle/vehicle row × pax counts)
→ applyPricingAdjustments(baseCost, markupValue, markupType, gstPercent, tcsPercent)
→ afterMarkup = baseCost + markup (flat or %)
→ afterGst = afterMarkup + (afterMarkup × gstPercent / 100)
→ finalTotal = afterGst + (afterGst × tcsPercent / 100)
→ applyDiscount(finalTotal, discountType, discountValue)
→ FLAT: finalTotal - discountValue
→ PERCENT: finalTotal - (finalTotal × discountValue / 100)
| Channel | What's returned | Markup applied? |
|---|---|---|
b2b |
Raw base price (admin sees cost price) | No |
b2c |
Base price + markup + GST + TCS | Yes |
The channel is set to "b2c" automatically in getBookingJwtPriceDetails() (the customer-facing price endpoint). Admin price endpoints use b2b by default.
PATCH /v1/itinerary/published-status/:itn_id
Guard: AuthGuard('jwt')
This is a toggle — calling it on a published itinerary unpublishes it, and vice versa.
itinerary.service.ts:1083)Validation only runs when publishing (not when unpublishing):
if (!itinerary?.isPublished) {
const publishContext = await itineraryRepository.getPublishValidationContext(itnId);
const result = validatePublishReadiness(publishContext);
if (!result.ok) throw new BadRequestException(result.errorCode);
}
validatePublishReadiness() (publish-validation.util.ts:45)The function checks different conditions based on what the trip contains:
IF hasHotels:
✓ effectiveCategory must not be null
→ ERR_ITN_PUBLISH_CATEGORY_REQUIRED
✓ bundlePricing must exist for that category
→ ERR_ITN_PUBLISH_HOTEL_PRICING_MISSING
✓ hotels must be mapped for all categories that have pricing
→ ERR_ITN_PUBLISH_HOTEL_MAPPING_MISSING_FOR_PRICED_CATEGORY
IF hasTransfers:
✓ a primary transfer must be mapped
→ ERR_ITN_PUBLISH_PRIMARY_TRANSFER_REQUIRED
✓ transferRows / bundleCapacityPrices must exist
→ ERR_ITN_PUBLISH_TRANSFER_PRICING_MISSING
IF activities_only (no hotels, no transfers, has activities):
✓ activitiesAdultPrice must not be null
→ ERR_ITN_PUBLISH_ACTIVITY_ADULT_PRICE_REQUIRED
The context for this check is fetched from getPublishValidationContext() in the repository, which runs joins to check all these conditions in one query.
itinerary.isPublished = true is stored in the DBGET /itinerary only returns published itinerariesGET /itinerary/:id?isLandingPage=true checks shouldHideUnpublishedItineraryOnLandingPage():isLandingPage=true and isPublished=false → throws ERR_ITN_NOT_FOUNDisLandingPage=false (admin/agent view) → shows even unpublished itinerariesIf the admin changes tripType on an already-published itinerary, the backend checks that pricing rows exist for the new category before allowing the change:
// itinerary.service.ts:540
if (isTripTypeChanged && itinerary?.isPublished) {
await this.assertBundlePricingExistsForCategory(id, requestedTripType);
// throws ERR_TRIP_TYPE_PRICING_ROW_REQUIRED if no pricing row for new category
}
// If unpublished and trip type changes:
if (isTripTypeChanged && !itinerary?.isPublished) {
await this.clearHotelDayMappingsForItinerary(id);
// removes all hotel day mappings since category changed
}
GET /v1/itinerary
Auth: None (public)
Rate Limit: @PublicRateLimit() (30 req/min)
UserItineraryPaginationDto)| Param | Purpose |
|---|---|
collectionId + collectionType |
Filter by a specific Collection (HEADER or LANDING) |
locationId + locationType |
Filter by city / state / country |
categoryId |
Filter by trip category (Adventure, Beach, etc.) |
startDate |
Filter trips available on a given date |
activityId |
Filter by activity type tag |
subHeaderId |
Sub-header within a header collection |
countryId, cityId |
Country/city filters for landing collections |
offset, limit |
Pagination |
itinerary.service.ts:861)Before running the main query, the service resolves which itinerary IDs to show based on the filter type:
CASE 1 — Collection-based filter (collectionId + collectionType set):
→ collectionService.getLandingItineraryList() or getHeaderItineraryList()
→ returns ordered list of itinerary IDs in the collection
→ if collectionId set but returns empty → return {} (no results shown)
CASE 2 — Location-based filter (locationId + locationType set):
→ getCityIdsForLocation() → expand region to city IDs
→ getItineraryIdsByCityIds() → find itineraries in those cities
→ if no itinerary IDs found → return undefined (run unrestricted query)
CASE 3 — No special filter:
→ pass undefined as itineraryIds (main query runs without ID filter)
After a listing query with startDate + locationType + locationId, the service non-blockingly calls:
seoSvc.createDynamicSeoVariant({ slug: 'trip-listing', locationType, locationId })
This generates a dynamic SEO page variant for the location+date combination. It runs fire-and-forget (no await).
GET /v1/itinerary/:id/customer-price-details (booking-jwt)
GET /v1/itinerary/:id/price-details-booking (booking-jwt, alias)
GET /v1/itinerary/:id/price-details (jwt, admin — b2b)
PriceDetailsQueryDto)| Param | Type | Purpose |
|---|---|---|
adults |
number | Override adult pax count |
children / childrenWithoutBed |
number | Override children (no extra mattress) |
extraMattressCount / childrenWithBed |
number | Children on extra mattress |
selectedCategory |
number | Hotel tier (HotelCategoryEnum) |
selectedTransferId |
number | Specific transfer vehicle ID |
selectedVehicleName |
string | Vehicle name (alternative to transferId) |
selectedCapacityMin |
number | Capacity slab min override |
selectedCapacityMax |
number | Capacity slab max override |
includeDinnerSupplement |
boolean | Add dinner supplement price |
channel |
string | b2b or b2c (admin only — auto-set to b2c for customer) |
itinerary.repository.ts:94)1. Fetch itinerary with joins:
→ days → resourceMappings → hotel/activity/flight
→ bundlePricing → capacityPrices → transfer
→ transferItnMapping → transfer
→ hotelItnMapping → hotel
2. applyTravelerCountOverrides()
→ if query params provided, override itinerary.travelerDetails defaults
3. Determine content flags:
hasActivities = days have activity or flight mappings
hasHotels = hotelItnMapping.length > 0 OR days have hotel mappings
hasTransfers = transferItnMapping.length > 0
4. resolvePricingTableType(hasHotels, hasTransfers, hasActivities)
→ "hotel_transfer" if hasHotels && hasTransfers
→ "hotel_only" if hasHotels && !hasTransfers
→ "transfer_only" if !hasHotels && hasTransfers
→ "activities_only" if !hasHotels && !hasTransfers && hasActivities
→ "activities_only" fallback
5. resolveAvailableCategories()
→ from bundleCategories (pricing rows) and mappedHotelCategories
→ de-duped and sorted
6. resolvePricingSelectionContext()
→ selects active category, bundle row, and transfer source bundle
7. resolveTravelerPricingInputs()
→ final adults, children, infants, extraMattressCount, includeDinnerSupplement
→ selectedVehicleName, selectedTransferId, selectedCapacityMin/Max
8. Vehicle row selection:
→ filterRowsByMappedTransfers() — only shows vehicles mapped to this itinerary
→ selectVehicleRowByTravelerChoice() — capacity slab matching:
totalPax = adults + children
finds row where capacityMin ≤ totalPax ≤ capacityMax
→ falls back to first row if no match
9. calculateTravelerPricing()
→ baseCost = (adultPrice × adults) + (childPrice × children) + (infantPrice × infants)
+ (extraBedPrice × extraMattressCount) [if applicable]
+ dinnerSupplementPrice [if includeDinnerSupplement=true]
10. getApplicableMarkupConfig() → markup type + amount
11. getApplicableGstFromMarkup() → GST percent
12. isInternationalItinerary() → TCS percent (2% if international, 0 if domestic)
13. applyPricingAdjustments(baseCost, markup, gst, tcs)
→ calculates afterMarkup, afterGst, finalTotal
14. applyDiscount(finalTotal, discountType, discountValue)
→ FLAT: subtract fixed amount
→ PERCENT: subtract percentage
15. Build and return full response object:
{
pricingTableType, // which scenario applies
availableCategories, // list of tiers with pricing
selection, // what was selected (category, vehicle slab, pax)
pricingTable, // all rows (filtered for b2c: only rows with price > 0)
summary, // final breakdown (markup, GST, TCS, discount, grandTotal)
travelerDetails, // per-person prices + grandTotal
extraMattressCount
}
B2B (admin, channel=b2b):
→ pricingTable.rows: all categories shown including ₹0 rows
→ categoryPerPersonPrices: raw base price (no markup)
→ summary: shows markup as a separate line item
B2C (customer, channel=b2c):
→ pricingTable.rows: only categories where perPersonPrice > 0 are shown
→ categoryPerPersonPrices: markup-adjusted price
→ activitiesOnly: markup-adjusted adult/child prices
→ summary: shows final price without separating out markup component
POST /v1/bookings
Guard: AuthGuard('booking-jwt')
CreateBookingDto Key Fields| Field | Purpose |
|---|---|
itineraryId |
Which trip |
startDate, endDate |
Trip dates |
travelers |
Array of {firstName, lastName, travelerType} — types: adult / child / infant |
email, phoneNumber, countryCode |
Contact info |
billingAddress |
GST billing address |
gstin, panNumber |
Tax details |
pricingSelection |
The exact pricing options chosen (category, vehicle slab, pax, etc.) |
bookings.service.ts:61)1. deleteDraftBookings(bookingUserId)
→ deletes any existing DRAFT bookings for this customer
→ ensures only one draft booking exists at a time
2. Count travelers from travelers array:
travelerCounts = { adults: n, children: n, infants: n }
(counts by travelerType field)
3. Validate pricingSelection:
→ must be present → ERR_PRICING_SELECTION_REQUIRED
→ channel must be 'b2c' → ERR_BOOKING_PRICING_CHANNEL_MUST_BE_B2C
4. Re-fetch price details at booking time:
itineraryService.getItineraryPriceDetails(itineraryId, {
...travelerCounts,
children: from pricingSelection or dto,
extraMattressCount: from pricingSelection or dto,
selectedCategory: from pricingSelection,
selectedTransferId: from pricingSelection.selectedVehicleSlab,
selectedVehicleName: from pricingSelection.selectedVehicleSlab,
selectedCapacityMin/Max: from pricingSelection.selectedVehicleSlab,
includeDinnerSupplement: from pricingSelection,
channel: 'b2c'
})
→ priceDetailsResponse.data = full price breakdown at current markup
5. bookingsRepository.createBooking({
itineraryId,
startDate, endDate,
bookingUserId, tenantId,
statusId: BookingStatus.DRAFT, ← always starts as DRAFT
priceDetails: priceDetailsResponse.data, ← SNAPSHOT stored
travelers,
billingAddress, gstin, panNumber,
email: email.toLowerCase()
})
6. Returns { booking } with DRAFT status
Critical: The priceDetails from step 4 are snapshotted into the Booking record at the moment of booking. Even if the itinerary pricing changes later, the booking retains the price it was created with.
enum BookingStatus {
DRAFT = 1, // Created, unpaid
PROCESSING = 2, // Payment captured, awaiting admin confirmation
CONFIRMED = 3, // Admin confirmed
COMPLETED = 4, // Trip completed
CANCELLED = 5, // Cancelled (admin or customer request)
}
POST /v1/payments/create-order
Guard: AuthGuard('booking-jwt')
Body: { bookingId }
1. Fetch booking (must be DRAFT, must belong to this customer)
2. getActiveGatewayConfig(tenantId):
→ fetch PaymentGatewayConfig from DB for this tenant
→ RAZORPAY_MODE env var selects test or live config
3. getRazorpayInstance(tenantId):
→ new Razorpay({ key_id, key_secret }) using tenant's credentials
4. convertToPaise(grandTotal):
→ uses Decimal.js: new Decimal(amount).mul(100).toNumber()
5. razorpay.orders.create({ amount, currency: 'INR', receipt: bookingId })
6. Save BookingPayment record: { bookingId, orderId, amount, status: PENDING }
7. Return { orderId, amount, currency, keyId } to frontend
The frontend uses keyId + orderId to open the Razorpay checkout modal. On payment success, Razorpay returns { razorpay_payment_id, razorpay_order_id, razorpay_signature }.
POST /v1/payments/verify
Guard: AuthGuard('booking-jwt')
Body: { bookingId, razorpay_payment_id, razorpay_order_id, razorpay_signature }
1. Fetch BookingPayment for this booking
2. HMAC-SHA256 signature verification:
expected = HMAC(orderId|paymentId, keySecret)
if expected !== razorpay_signature → throw ERR_INVALID_PAYMENT_SIGNATURE
3. Idempotency check:
if payment.status === CAPTURED → return early (already processed)
4. razorpayInstance.payments.capture(paymentId, amount)
5. Update BookingPayment.status → CAPTURED
6. Log TransactionLog entry (SUCCESS)
7. syncBookingStatusWithPayment():
if booking.statusId === DRAFT:
→ bookingsService.updateBookingStatus(bookingId, PROCESSING)
→ triggers itinerary snapshot build and PDF data preparation
8. Send booking-in-process email (non-blocking)
9. Return success
POST /v1/payments/webhook
Auth: None (public — Razorpay webhook)
TenantMiddleware: EXCLUDED
The webhook handles these events from Razorpay:
| Event | Action |
|---|---|
payment.captured |
Idempotent — re-confirms CAPTURED status if missed |
payment.failed |
Updates BookingPayment.status → FAILED, logs |
refund.processed |
Updates refund record, logs |
refund.failed |
Logs failure |
Important: The webhook URL is excluded from TenantMiddleware because Razorpay sends the webhook without tenant context. The service resolves the tenant from the booking record itself.
PUT /v1/bookings/:id/status
Guard: AuthGuard('jwt') + PermissionGuard
Permission: Permission.BOOKING
Body: { statusId, sendEmail? }
When admin changes status to CONFIRMED:
1. Fetch booking with itinerary
2. daysService.getDayWiseItineraryDetails(booking.itineraryId)
3. buildBookingItinerarySnapshot(dayWiseData, booking.priceDetails)
→ builds a day-wise snapshot of the itinerary as it is NOW
→ merged with the priced snapshot from booking time
4. Fetch itinerary policies (paymentPolicy, cancellationPolicy, notes)
5. bookingsRepository.prepareBookingPdfData() — assembles full PDF data
6. bookingsRepository.updateBookingStatus(bookingId, statusId, itineraryDetails)
→ stores itinerarySnapshot on booking for PDF generation
7. if dto.sendEmail && status changed to CONFIRMED:
→ sendBookingConfirmationEmails(bookingId) [non-blocking]
→ sends confirmation email with itinerary PDF
8. if dto.sendEmail && status changed to PROCESSING:
→ razorpayService.sendBookingInProcessEmails(bookingId, false) [non-blocking]
POST /v1/bookings/:bookingId/cancel
Body: { reason?, refundAmount? }
1. Fetch booking (must be PROCESSING or CONFIRMED)
2. If refundAmount > 0:
→ razorpayService.processRefund(bookingId, refundAmount, reason)
→ calls Razorpay refund API
→ logs RefundTransactionLog
3. Update booking.statusId → CANCELLED
4. Send cancellation email to customer
POST /v1/bookings/:id/cancel-request
Guard: AuthGuard('booking-jwt')
Customer can only request cancellation — they do not cancel directly. This sets a flag on the booking visible to admins. The actual cancellation + refund is done by an admin via the admin cancel endpoint.
GET /v1/bookings/:bookingId/confirmation-voucher → fetch existing voucher data
PUT /v1/bookings/:bookingId/confirmation-voucher → admin fills in hotel/service voucher details
POST /v1/bookings/:bookingId/confirmation-voucher/media → upload supporting docs (S3)
POST /v1/bookings/:bookingId/confirmation-voucher/generate-pdf → generate PDF
POST /v1/bookings/:bookingId/confirmation-voucher/send-email → email voucher to customer
Voucher media files:
image/jpeg, image/png, application/pdfS3BucketTypeEnum.VOUCHER_MEDIAresolvePricingTableType(hasHotels, hasTransfers, hasActivities):
if hasHotels && hasTransfers → "hotel_transfer"
if hasHotels && !hasTransfers → "hotel_only"
if !hasHotels && hasTransfers → "transfer_only"
else → "activities_only"
This determines which rows from ItineraryBundlePricing to use for pricing.
The capacity slab is matched based on total pax (adults + children):
totalPax = adults + children
Find row where: capacityMin ≤ totalPax ≤ capacityMax
→ if no match: fall back to first row (lowest slab)
Further filtering:
→ if selectedTransferId provided: match on transferId
→ if selectedVehicleName provided: match on vehicleName
→ if both capacityMin + capacityMax provided: exact slab match
resolveAvailableCategories() determines which hotel tiers to show:
Sources:
bundleCategories → categories that have ItineraryBundlePricing rows
mappedHotelCategories → categories of hotels in HotelItineraryMapping
If pricingTableType is hotel_transfer or hotel_only:
→ intersection of bundleCategories and mappedHotelCategories
→ (a category must have BOTH pricing AND a mapped hotel to appear)
If pricingTableType is transfer_only or activities_only:
→ from bundleCategories only (or normalizedTripCategory as single value)
The transfer vehicle rows can come from different sources depending on the scenario:
For hotel_transfer:
bundleVehicleRows = selectedBundle.capacityPrices
→ filtered to only vehicles mapped to this itinerary
For transfer_only:
transferPricingSourceBundle.capacityPrices
→ also filtered by mapped transfer IDs/names
Fallback (when no capacity prices defined):
fallbackTransferVehicleRows
→ generates default slabs from transfer.seater:
{ capacityMin: 1, capacityMax: 3, pricePerPerson: 0 }
{ capacityMin: 4, capacityMax: seater, pricePerPerson: 0 } (if seater > 3)
applyPricingAdjustments() FormulaGiven: baseCost, markupValue, markupType (FLAT=1 or PERCENTAGE=2), gstPercent, tcsPercent
if markupType === PERCENTAGE:
afterMarkup = baseCost + (baseCost × markupValue / 100)
else (FLAT):
afterMarkup = baseCost + markupValue
afterGst = afterMarkup + (afterMarkup × gstPercent / 100)
finalTotal = afterGst + (afterGst × tcsPercent / 100)
Returns: { afterMarkup, afterGst, finalTotal }
applyDiscount() FormulaGiven: price, discountType (FLAT or PERCENT), discountValue
if discountType === FLAT:
result = price - discountValue
if discountType === PERCENT:
result = price - (price × discountValue / 100)
if result < 0: result = 0
Discount is applied after markup + GST + TCS.
adultPrice = selectedBundle.pricePerPerson (from the category row)
OR selectedVehicleRow.pricePerPerson (for vehicle-based pricing)
childPrice = childWithoutBedPrice (from bundle row)
infantPrice = 0 (infants are typically free)
extraBedPrice = bundle.extraBedPrice (per extra mattress)
baseCost = (adultPrice × adults)
+ (childPrice × children)
+ (infantPrice × infants)
+ (extraBedPrice × extraMattressCount)
+ (dinnerSupplementPrice [if includeDinnerSupplement=true])
Itineraries (id, name, tripType, isPublished, pricePerPerson, travelerDetails, token)
│
├── Days (id, itnId, locationId, order, title, date)
│ └── DayResourceMapping (id, dayId, hotelId?, activityId?, flightId?, order)
│
├── ItineraryDates (id, itineraryId, startDate, endDate)
│
├── ItnActivityTypes (id, itnId, activityTypes)
│
├── HotelItineraryMapping (id, hotelId, itineraryId, category, cityId, isPrimary)
│ └── Hotels (id, name, price, categoryId, cityId, tenantId)
│
├── TransferItineraryMapping (id, transferId, itineraryId, isPrimary)
│ └── Transfers (id, vehicleName, seater, tenantId)
│
├── ItineraryBundlePricing (id, itineraryId, category, plan, extraBedPrice,
│ │ childWithoutBedPrice, dinnerSupplementPrice, isActive)
│ └── BundleCapacityPrice (id, bundlePricingId, transferId, capacityMin,
│ capacityMax, pricePerPerson, vehicleName, isActive)
│
├── MarkupItineraryMapping → Markup (type, amount, isGlobal, isActive, category)
│
└── ItineraryInfo (id, itineraryId, text, mediaFiles[])
└── InfoMedia (id, itineraryInfoId, fileName, fileType)
Booking (id, bookingReferenceId, itineraryId, bookingUserId, statusId, priceDetails JSON)
├── BookingTraveler (id, bookingId, firstName, lastName, travelerType)
├── BookingPayment (id, bookingId, orderId, paymentId, amount, status)
├── ConfirmationVoucher (id, bookingId, details JSON)
│ └── VoucherMedia (id, voucherId, fileName, fileType)
└── TransactionLog (id, bookingId, operation, status, errorMessage, metadata JSON)
| Code | Meaning |
|---|---|
ERR_MAX_DATE_LENGTH |
isTour=true but more than one date range provided |
ERR_SUBSCRIPTION_LIMIT_REACHED |
Tenant has hit the trip creation limit |
ERR_INVALID_ACTIVITY_TYPES_ID |
One or more activityTypeId values don't exist |
ERR_DUPLICATE_DATA |
Activity type already mapped to this itinerary |
ERR_ITN_NOT_FOUND |
Itinerary ID not found for this tenant |
ERR_TRIP_TYPE_PRICING_ROW_REQUIRED |
Trying to change tripType on published itn without pricing row for new type |
| Code | Meaning |
|---|---|
ERR_ITN_PUBLISH_CATEGORY_REQUIRED |
No hotel category (tripType) set |
ERR_ITN_PUBLISH_HOTEL_PRICING_MISSING |
No bundlePricing row for the itinerary's category |
ERR_ITN_PUBLISH_HOTEL_MAPPING_MISSING_FOR_PRICED_CATEGORY |
Hotel not mapped for a category that has pricing |
ERR_ITN_PUBLISH_PRIMARY_TRANSFER_REQUIRED |
No primary transfer mapped |
ERR_ITN_PUBLISH_TRANSFER_PRICING_MISSING |
Transfer has no capacity/price rows |
ERR_ITN_PUBLISH_ACTIVITY_ADULT_PRICE_REQUIRED |
Activities-only trip with no adult price set |
| Code | Meaning |
|---|---|
ERR_PRICING_SELECTION_REQUIRED |
No pricingSelection in booking request |
ERR_BOOKING_PRICING_CHANNEL_MUST_BE_B2C |
Customer tried to book with non-b2c channel |
ERR_RAZORPAY_CONFIG_NOT_FOUND |
No active Razorpay config for this tenant + mode |
ERR_INVALID_PAYMENT_SIGNATURE |
HMAC verification failed — possible tamper |
ERR_BOOKING_NOT_FOUND |
Booking ID not found |
ERR_HOTEL_LOCATION_REQUIRED |
Import: hotel has no cityId |
ERR_TRANSFER_NOT_FOUND |
Import: transfer ID not found for tenant |
Generated from live codebase inspection of M-M_Trip_Backend — itinerary.service.ts (5553 lines), itinerary.repository.ts (5010 lines), bookings.service.ts (2011 lines), razorpay.service.ts (1591 lines)