{
  "info": {
    "_postman_id": "2fc9cb53-d5d5-48cc-b5b5-18cd523519de",
    "name": "Safiri Flight Booking for Third-Party Agents",
    "description": "Run the requests in order to fetch airport stops, search, review, reserve, purchase, and retrieve issued flight tickets. The changed-price request is separate so the latest price can be shown to the traveler before it is accepted.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "const dateOnly = (date) => date.toISOString().slice(0, 10);",
          "const today = new Date();",
          "const currentDeparture = pm.environment.get('departure_date');",
          "const parsedDeparture = new Date(`${currentDeparture}T00:00:00Z`);",
          "const departureIsInvalid = !currentDeparture || Number.isNaN(parsedDeparture.getTime()) || parsedDeparture <= today;",
          "let departure = parsedDeparture;",
          "",
          "if (departureIsInvalid) {",
          "  departure = new Date(today);",
          "  departure.setUTCDate(departure.getUTCDate() + 30);",
          "  pm.environment.set('departure_date', dateOnly(departure));",
          "}",
          "",
          "const currentReturn = pm.environment.get('return_date');",
          "const parsedReturn = new Date(`${currentReturn}T00:00:00Z`);",
          "if (!currentReturn || Number.isNaN(parsedReturn.getTime()) || parsedReturn <= departure) {",
          "  const returnDate = new Date(departure);",
          "  returnDate.setUTCDate(returnDate.getUTCDate() + 7);",
          "  pm.environment.set('return_date', dateOnly(returnDate));",
          "}"
        ]
      }
    }
  ],
  "item": [
    {
      "name": "1. Authenticate",
      "description": "Use an active third-party agent account with enough float balance for the booking.",
      "item": [
        {
          "name": "Log in",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('Login succeeds', () => pm.response.to.be.success);",
                  "const body = pm.response.json();",
                  "const token = body.token || body.jwt || body.data?.token;",
                  "pm.expect(token, 'Bearer token').to.be.ok;",
                  "pm.environment.set('jwt_token', token);"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"email\": \"{{agent_email}}\",\n  \"password\": \"{{agent_password}}\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/logon",
              "host": ["{{base_url}}"],
              "path": ["api", "logon"]
            }
          }
        }
      ]
    },
    {
      "name": "2. Book a flight",
      "description": "Run in order. Do not run the changed-price acceptance request unless the reservation returned FLIGHT_PRICE_CHANGED and the traveler approved the latest price.",
      "item": [
        {
          "name": "1. Get airport stops",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('Airport stops are returned', () => pm.response.to.be.success);",
                  "const stops = pm.response.json();",
                  "pm.expect(stops).to.be.an('array').that.is.not.empty;",
                  "pm.expect(stops[0].stop_code, 'stop_code').to.match(/^[A-Z0-9]{3}$/);"
                ]
              }
            }
          ],
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{gtfs_flight_url}}/api/stops",
              "host": ["{{gtfs_flight_url}}"],
              "path": ["api", "stops"]
            },
            "description": "Use stop_code directly as originLocationCode or destinationLocationCode. If needed, you can also extract the three-letter prefix from stop_id; for example, JRO:FLT:0 becomes JRO. Do not send the complete stop_id."
          }
        },
        {
          "name": "2. Search flights",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('Search succeeds', () => pm.response.to.be.success);",
                  "const body = pm.response.json();",
                  "const offers = Array.isArray(body.data) ? body.data : [];",
                  "const selected = offers[0];",
                  "pm.environment.unset('offer_reference');",
                  "pm.environment.unset('suggested_departure_date');",
                  "pm.environment.unset('suggested_return_date');",
                  "pm.environment.unset('price_version');",
                  "pm.environment.unset('price_amount');",
                  "pm.environment.unset('price_currency');",
                  "pm.test('Count describes requested-date offers', () => {",
                  "  pm.expect(body.meta?.count).to.equal(offers.length);",
                  "});",
                  "",
                  "if (selected) {",
                  "  pm.test('Requested-date offer can be reviewed', () => {",
                  "    pm.expect(selected.offerReference, 'offerReference').to.be.ok;",
                  "    pm.expect(body.meta?.availability?.status).to.equal('RESULTS_FOUND');",
                  "    pm.expect(body.meta?.availability?.lookAhead?.status).to.equal('NOT_NEEDED');",
                  "  });",
                  "  pm.environment.set('offer_reference', selected.offerReference);",
                  "} else {",
                  "  const availability = body.meta?.availability;",
                  "  pm.test('Empty requested-date result has an actionable look-ahead state', () => {",
                  "    pm.expect(body.meta?.count).to.equal(0);",
                  "    pm.expect(availability?.status).to.equal('NO_RESULTS');",
                  "    pm.expect(['FOUND', 'NO_RESULTS', 'INCOMPLETE']).to.include(availability?.lookAhead?.status);",
                  "  });",
                  "",
                  "  if (availability?.lookAhead?.status === 'FOUND') {",
                  "    pm.environment.set('suggested_departure_date', availability.next.departureDate);",
                  "    if (availability.next.returnDate) {",
                  "      pm.environment.set('suggested_return_date', availability.next.returnDate);",
                  "    }",
                  "    console.warn('Confirm the suggested dates, copy them into departure_date/return_date, then rerun the exact search. No offerReference was created.');",
                  "  } else {",
                  "    console.warn('No offerReference was created because the requested-date search was empty.');",
                  "  }",
                  "",
                  "  pm.execution.setNextRequest(null);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{jwt_token}}",
                "type": "text"
              },
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "Content-Language",
                "value": "en",
                "type": "text"
              },
              {
                "key": "Content-Currency",
                "value": "USD",
                "type": "text"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"originLocationCode\": \"{{origin_airport}}\",\n  \"destinationLocationCode\": \"{{destination_airport}}\",\n  \"departureDate\": \"{{departure_date}}\",\n  \"returnDate\": \"{{return_date}}\",\n  \"adults\": 1,\n  \"children\": 0,\n  \"infants\": 0,\n  \"travelClass\": \"ECONOMY\",\n  \"nonStop\": false,\n  \"currencyCode\": \"USD\",\n  \"max\": 10,\n  \"lookAheadDays\": 7\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/flight/search",
              "host": ["{{base_url}}"],
              "path": ["api", "flight", "search"]
            },
            "description": "This example opts in to checking exactly seven later date pairs after an empty requested-date result. POST /api/flight/offers/search is an identical alias. currencyCode takes precedence over Content-Currency. If data contains offers, the first offerReference is saved for this example. If data is empty, no offerReference is created and the collection run stops. For FOUND, inspect suggested_departure_date and suggested_return_date, obtain confirmation, copy them into departure_date and return_date, and rerun this exact request; all other filters and lookAheadDays stay unchanged."
          }
        },
        {
          "name": "3. Review selected offer",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('Offer review succeeds', () => pm.response.to.be.success);",
                  "const body = pm.response.json();",
                  "pm.expect(body.expiresAt, 'offer expiry').to.be.ok;",
                  "pm.expect(body.bookingRequirements, 'booking requirements').to.be.ok;"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{jwt_token}}",
                "type": "text"
              },
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"offerReference\": \"{{offer_reference}}\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/flight/offer",
              "host": ["{{base_url}}"],
              "path": ["api", "flight", "offer"]
            },
            "description": "Inspect expiresAt and bookingRequirements before collecting traveler details."
          }
        },
        {
          "name": "4. Create reservation",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const body = pm.response.json();",
                  "",
                  "if (pm.response.code >= 200 && pm.response.code < 300 && pm.response.code !== 202) {",
                  "  const reservation = body.reservation || {};",
                  "  const ticketId = reservation.ticketIds?.[0] || body.tickets?.[0]?.ticketId;",
                  "  pm.environment.set('reservation_id', reservation.reservationId);",
                  "  pm.environment.set('booking_reference', reservation.bookingReference);",
                  "  pm.environment.set('ticket_id', ticketId);",
                  "}",
                  "",
                  "if (body.errorCode === 'FLIGHT_PRICE_CHANGED') {",
                  "  pm.environment.set('price_version', body.data.priceVersion);",
                  "  pm.environment.set('price_amount', body.data.currentPrice.grandTotal);",
                  "  pm.environment.set('price_currency', body.data.currentPrice.currency);",
                  "}",
                  "",
                  "pm.test('Reservation response is actionable', () => {",
                  "  pm.expect([200, 201, 202, 409, 422]).to.include(pm.response.code);",
                  "});"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{jwt_token}}",
                "type": "text"
              },
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "X-External-User-Id",
                "value": "{{external_user_id}}",
                "type": "text"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"offerReference\": \"{{offer_reference}}\",\n  \"passengers\": [\n    {\n      \"firstName\": \"Asha\",\n      \"lastName\": \"Mushi\",\n      \"email\": \"asha.mushi@example.com\",\n      \"countryCode\": \"255\",\n      \"phoneNumber\": \"712345678\",\n      \"dateOfBirth\": \"1990-05-12\",\n      \"gender\": \"FEMALE\",\n      \"ageCategory\": \"ADULT\",\n      \"documentType\": \"PASSPORT\",\n      \"passportNumber\": \"TZ1234567\",\n      \"passportExpiryDate\": \"2030-05-12\",\n      \"documentIssuingCountry\": \"TZ\",\n      \"nationality\": \"TZ\"\n    }\n  ],\n  \"selectedOption\": \"NON_REFUNDABLE_FARE\",\n  \"notifications\": {\n    \"safiriAccountCreation\": true,\n    \"ticketPurchase\": true,\n    \"allOtherTripNotification\": true\n  }\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/flight/reserve/agent",
              "host": ["{{base_url}}"],
              "path": ["api", "flight", "reserve", "agent"]
            },
            "description": "Correct every returned traveler field before retrying. If the price changes, show it to the traveler before using the separate acceptance request. Third-party agent bookings never send customer reservation, unpaid-reservation reminder, or reservation-cancellation notifications. Final ticket notifications are enabled by default; set notifications.ticketPurchase to false to suppress them, and keep that value unchanged on retries."
          }
        },
        {
          "name": "4b. Accept approved changed price",
          "event": [
            {
              "listen": "prerequest",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (!pm.environment.get('price_version') || !pm.environment.get('price_amount') || !pm.environment.get('price_currency')) {",
                  "  throw new Error('Run this request only after a changed price is returned and approved');",
                  "}"
                ]
              }
            },
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const body = pm.response.json();",
                  "",
                  "if (pm.response.code >= 200 && pm.response.code < 300 && pm.response.code !== 202) {",
                  "  const reservation = body.reservation || {};",
                  "  const ticketId = reservation.ticketIds?.[0] || body.tickets?.[0]?.ticketId;",
                  "  pm.environment.set('reservation_id', reservation.reservationId);",
                  "  pm.environment.set('booking_reference', reservation.bookingReference);",
                  "  pm.environment.set('ticket_id', ticketId);",
                  "}",
                  "",
                  "if (body.errorCode === 'FLIGHT_PRICE_CHANGED') {",
                  "  pm.environment.set('price_version', body.data.priceVersion);",
                  "  pm.environment.set('price_amount', body.data.currentPrice.grandTotal);",
                  "  pm.environment.set('price_currency', body.data.currentPrice.currency);",
                  "}",
                  "",
                  "pm.test('Price acceptance response is actionable', () => {",
                  "  pm.expect([200, 201, 202, 409, 422]).to.include(pm.response.code);",
                  "});"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{jwt_token}}",
                "type": "text"
              },
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              },
              {
                "key": "X-External-User-Id",
                "value": "{{external_user_id}}",
                "type": "text"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"offerReference\": \"{{offer_reference}}\",\n  \"passengers\": [\n    {\n      \"firstName\": \"Asha\",\n      \"lastName\": \"Mushi\",\n      \"email\": \"asha.mushi@example.com\",\n      \"countryCode\": \"255\",\n      \"phoneNumber\": \"712345678\",\n      \"dateOfBirth\": \"1990-05-12\",\n      \"gender\": \"FEMALE\",\n      \"ageCategory\": \"ADULT\",\n      \"documentType\": \"PASSPORT\",\n      \"passportNumber\": \"TZ1234567\",\n      \"passportExpiryDate\": \"2030-05-12\",\n      \"documentIssuingCountry\": \"TZ\",\n      \"nationality\": \"TZ\"\n    }\n  ],\n  \"selectedOption\": \"NON_REFUNDABLE_FARE\",\n  \"notifications\": {\n    \"safiriAccountCreation\": true,\n    \"ticketPurchase\": true,\n    \"allOtherTripNotification\": true\n  },\n  \"priceAcceptance\": {\n    \"priceVersion\": \"{{price_version}}\",\n    \"amount\": \"{{price_amount}}\",\n    \"currency\": \"{{price_currency}}\"\n  }\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/flight/reserve/agent",
              "host": ["{{base_url}}"],
              "path": ["api", "flight", "reserve", "agent"]
            },
            "description": "Run only after showing the saved latest price to the traveler and receiving approval. If another price change is returned, obtain approval again before retrying. Keep notifications.ticketPurchase unchanged from the original reservation request."
          }
        },
        {
          "name": "5. Purchase reservation",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('Purchase is issued or still processing', () => {",
                  "  pm.expect([200, 201, 202]).to.include(pm.response.code);",
                  "});"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{jwt_token}}",
                "type": "text"
              },
              {
                "key": "Content-Type",
                "value": "application/json",
                "type": "text"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"ticketId\": \"{{ticket_id}}\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/flight/purchase/agent",
              "host": ["{{base_url}}"],
              "path": ["api", "flight", "purchase", "agent"]
            },
            "description": "Purchase before paymentDeadlineAt. If the request is still being handled, wait briefly and send the same purchase request again. Successful purchase sends final ticket notifications by default unless the reservation set notifications.ticketPurchase to false."
          }
        },
        {
          "name": "6. Retrieve issued tickets",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "pm.test('Issued tickets are returned', () => pm.response.to.be.success);"
                ]
              }
            }
          ],
          "request": {
            "method": "GET",
            "header": [
              {
                "key": "Authorization",
                "value": "Bearer {{jwt_token}}",
                "type": "text"
              }
            ],
            "url": {
              "raw": "{{base_url}}/api/flight/tickets?ticketId={{ticket_id}}",
              "host": ["{{base_url}}"],
              "path": ["api", "flight", "tickets"],
              "query": [
                {
                  "key": "ticketId",
                  "value": "{{ticket_id}}"
                }
              ]
            },
            "description": "Retrieve the issued tickets and display the booking reference and ticket details to the traveler."
          }
        }
      ]
    }
  ],
  "variable": [
    {
      "key": "base_url",
      "value": "https://booking-api-dev.safiri.app",
      "type": "string"
    },
    {
      "key": "gtfs_flight_url",
      "value": "https://gtfs-flight.safiri.app",
      "type": "string"
    }
  ]
}
