Itinerary: Full Lifecycle Flow

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


Table of Contents

  1. High-Level Overview
  2. Phase 1 — Itinerary Creation (B2B)
  3. Phase 2 — Days, Content & Resource Setup
  4. Phase 3 — Pricing Setup
  5. Phase 4 — Markup & GST Application
  6. Phase 5 — Publish Validation & Publishing
  7. Phase 6 — B2C Trip Discovery & Listing
  8. Phase 7 — Customer Price Calculation (B2C)
  9. Phase 8 — Booking Creation
  10. Phase 9 — Payment via Razorpay
  11. Phase 10 — Admin Booking Confirmation Flow
  12. Pricing Engine Deep Dive
  13. Entity Relationship Map
  14. Error Codes Reference

1. High-Level Overview

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

2. Phase 1 — Itinerary Creation (B2B)

Endpoint

POST /v1/itinerary
Guard: AuthGuard('jwt') + PermissionGuard
Permission: Permission.TRIP
Content-Type: multipart/form-data (banner image is optional)

Key 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

What Happens on Create (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()

tripTypeHotelCategoryEnum 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).


3. Phase 2 — Days, Content & Resource Setup

After the itinerary is created, the admin builds out its content through several separate operations. All are done via the admin dashboard (B2B).

3.1 Day Structure

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:

3.2 Hotel Mapping

Hotels 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.

3.3 Transfer Mapping

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.

3.4 Activities

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.


4. Phase 3 — Pricing Setup

Endpoint

PUT /v1/itinerary/:id/pricing-table
Guard: AuthGuard('jwt') + PermissionGuard
Permission: Permission.TRIP

The 4 Pricing Scenarios

The scenario field in PricingTableUpsertDto determines which data structure is expected:


Scenario 1: hotel_transfer

Used 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:


Scenario 2: hotel_only

Used 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.


Scenario 3: transfer_only

Used 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.


Scenario 4: activities_only

Used 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 Method

itinerary.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.


The savePaxDetails Method (Traveler Details)

PUT /v1/itinerary/:id/travelers

Saves the default traveler configuration for the itinerary:

These defaults are used when a customer views the trip without selecting specific options — the price shown is calculated with these defaults.


5. Phase 4 — Markup & GST Application

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.

Markup Resolution (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

GST Resolution (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

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).

Price Calculation Chain

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)

B2B vs B2C Price Difference

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.


6. Phase 5 — Publish Validation & Publishing

Endpoint

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.

Publish Validation (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.

What "Published" Means

Trip Type Change Guard

If 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
}

7. Phase 6 — B2C Trip Discovery & Listing

Endpoint

GET /v1/itinerary
Auth: None (public)
Rate Limit: @PublicRateLimit() (30 req/min)

Query Parameters (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 ID Resolution (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)

SEO Trigger (Side Effect)

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).


8. Phase 7 — Customer Price Calculation (B2C)

Endpoints

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)

Query Parameters (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)

Price Calculation Steps (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
    }

B2C vs B2B in Price Response

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

9. Phase 8 — Booking Creation

Endpoint

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.)

Booking Creation Flow (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.

Booking Status Values

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)
}

10. Phase 9 — Payment via Razorpay

Step 1: Create Order

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

Step 2: Frontend Checkout

The frontend uses keyId + orderId to open the Razorpay checkout modal. On payment success, Razorpay returns { razorpay_payment_id, razorpay_order_id, razorpay_signature }.

Step 3: Verify Payment

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

Step 4: Webhook (Async Confirmation)

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.


11. Phase 10 — Admin Booking Confirmation Flow

Update Status

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]

Cancel Booking (Admin)

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

Cancel Request (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.

Confirmation Voucher Flow

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:


12. Pricing Engine Deep Dive

Pricing Table Type Resolution

resolvePricingTableType(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.

Vehicle Slab Selection

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

Available Categories Logic

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)

Transfer Rows Source

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() Formula

Given: 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() Formula

Given: 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.

Per-Person Price Calculation

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])

13. Entity Relationship Map

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)

14. Error Codes Reference

Itinerary Creation / Update

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

Publish Validation

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

Booking / Payment

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_Backenditinerary.service.ts (5553 lines), itinerary.repository.ts (5010 lines), bookings.service.ts (2011 lines), razorpay.service.ts (1591 lines)