# PRS — Service Verticals Integration Guide (for Voyager / Luna)

How Voyager prices **non-hotel itinerary items** through PRS: transfers, car hire, trains, ferries, tours/activities/tickets, chauffeur, and dining. Hotels keep using the existing hotel flow (see the main README + `ALEXANDER_NOTE_city_name.md`) — this doc is everything else.

**Base URL:** `https://pricingresolver.symbiofoundry.com`
**Interactive reference:** `/docs` (Swagger UI) · `/redoc` · raw spec at `/openapi.json`

---

## 1. The model in one paragraph

Every itinerary item is **one `POST /v1/resolutions`**. You set `service_type` to pick the vertical; PRS fans out to **every enabled supplier in that vertical in parallel and returns the cheapest** (`service_prices.cheapest`). One request = one item — for a multi-item itinerary, send one POST per item. There is no separate endpoint per vertical; the `service_type` discriminator routes it.

---

## 1a. Luna service-type nomenclature

Luna sends its own inherited service-type **codes** in `service_type`. PRS translates each to its internal vertical, prices as normal, and **echoes the original code back** as `requested_service_type` so Luna sees its own nomenclature in the answer.

| Luna code | PRS vertical | Meaning |
|---|---|---|
| `RST` | `restaurant` | restaurant / dining |
| `GUI` | `tour` | guide |
| `ENT` | `tour` | entertainment |
| `ATR` | `tour` | attraction |
| `MTC` | `transfer` | transfer / transport |
| `FRY` | `ferry` | ferry crossing |

- **`GUI` / `ENT` / `ATR` all price through the `tour` vertical.** The exact code you sent is preserved in `requested_service_type` (and added as a `tour.interests` hint), so the guide/entertainment/attraction distinction round-trips even though they share a vertical.
- Send the per-vertical payload block under the **PRS block name** (`transfer`, `tour`, `restaurant`, …) — recommended. PRS also accepts the block under the Luna code key as a fallback.
- **Native PRS service types** (`transfer`, `tour`, …) are still accepted unchanged.
- **Unknown codes** return `400` with the accepted list.
- Codes for `car_hire` / `train` / `chauffeur` / `hotel` are **pending** from the Luna team — send them and we'll add them (one-line change in `services/luna-codes.ts`).

**Round-trip:** every `ResolutionDetail` + webhook carries both `service_type` (PRS canonical) and `requested_service_type` (your code). **Read `requested_service_type`** to match the answer to what you sent.

```jsonc
// request — Luna sends its code; block under the PRS name
{ "tour_id": "t1", "source": "itinerary_builder", "service_type": "RST",
  "restaurant": { "city_code": "PAR", "country_code": "FR", "date": "2026-05-16", "party_size": 2 } }

// response (detail / webhook) carries both:
{ "service_type": "restaurant", "requested_service_type": "RST", "service_prices": { /* … */ } }
```

---

## 2. Auth, headers, idempotency

Every call to `/v1/*`:

```
POST /v1/resolutions
X-API-Key: <your PRS key>
Idempotency-Key: <unique per item>     ← REQUIRED (400 if missing). Re-sending the same key returns the existing resolution instead of starting a new one.
Content-Type: application/json
```

---

## 3. Sync vs async — getting the result

**Async (default):** PRS replies `202 Accepted` immediately:
```json
{ "resolution_id": "res_…", "status": "ACCEPTED", "created_at": "…", "estimated_completion_at": "…" }
```
Then either:
- **Webhook** — if you pass `webhook_url`, PRS POSTs the terminal event there (HMAC-signed; header `X-PRS-Signature: sha256=<hex>` over the raw body). Service events:
  - `prs.resolution.reconciled` — a quote was obtained (`service_prices.cheapest` populated)
  - `prs.resolution.no_supplier` — no supplier could quote (see `reason` / `per_supplier_failures`)
  - `prs.resolution.failed` — system error (bad input, timeout)
- **Poll** — `GET /v1/resolutions/{resolution_id}` until `status` is `COMPLETED` / `FAILED`.

**Sync (simplest for itinerary building):** add `?wait=true` (optionally `&wait_seconds=N`, 5–120, default 60). PRS blocks until the resolution completes and returns the **full `ResolutionDetail`** (HTTP `200`) inline — no webhook/poll needed:
```
POST /v1/resolutions?wait=true&wait_seconds=45
```

Other reads:
- `GET /v1/resolutions/{id}` → full detail
- `GET /v1/resolutions/{id}/events` → audit trail
- `POST /v1/resolutions/{id}/cancel` → cancel (409 if already terminal)

---

## 4. Common request envelope

Every service request shares these top-level fields:

| Field | Req | Notes |
|---|---|---|
| `tour_id` | ✅ | NOVA/Voyager tour id this item belongs to |
| `source` | ✅ | `itinerary_builder` \| `intake_form` \| `direct_api` \| `client_preference_transfer` |
| `service_type` | ✅ | the vertical (see below) |
| `<vertical block>` | ✅ | one block named after the vertical (e.g. `transfer`, `tour`) |
| `inquiry_id` | — | your cross-system id |
| `client_context` | — | `{ agent_code, agent_name, tour_name }` |
| `webhook_url` | — | where terminal events are POSTed (async) |
| `nova_service_id` | — | Luna/NOVA service item id — captured and **echoed back** in the response so you can correlate (also accepted inside the vertical block) |

`service_type` accepts Luna's codes (see §1a) or the native PRS names. The response echoes `requested_service_type` (your code) + `nova_service_id`.

---

## 5. The verticals

> **Timestamps** are ISO 8601 **with a timezone offset** (e.g. `2026-05-15T16:30:00+02:00`). **Dates** are `YYYY-MM-DD`.

### transfer — point-to-point transfer · supplier: Daytrip · **LIVE (sandbox)**
**Gotcha:** `from`/`to` must each carry **`geo {latitude,longitude}` or `iata_code`** — PRS has no geocoder, so an address alone yields no quote.
```json
{
  "tour_id": "t1", "source": "itinerary_builder", "service_type": "transfer",
  "transfer": {
    "from": { "iata_code": "CDG", "geo": { "latitude": 49.0097, "longitude": 2.5479 } },
    "to":   { "geo": { "latitude": 48.8443, "longitude": 2.3270 } },
    "pickup_at": "2026-05-15T16:30:00+02:00",
    "pax": { "adults": 2 }, "luggage": 3,
    "vehicle_preference": "any"   // sedan|van|minibus|coach|any
  }
}
```
**Party size:** a Daytrip transfer is a **single private vehicle**; the largest (a van) seats **~7**, so a party of **8+ returns `no_coverage`** (the message says so explicitly). For a larger group, send **one transfer request per vehicle** (e.g. split 9 pax into 7 + 2) — each is quoted independently — or use a coach supplier. `vehicle_preference` filters to a class; if no offered vehicle fits both the pax and the preference you get `no_matching_option`.

### chauffeur — hourly driver+vehicle · supplier: Daytrip · **LIVE (sandbox)**
**Gotcha:** `pickup` needs `geo` or `iata_code`.
```json
{
  "tour_id": "t1", "source": "itinerary_builder", "service_type": "chauffeur",
  "chauffeur": {
    "pickup": { "iata_code": "PRG" },
    "pickup_at": "2026-09-10T10:00:00+02:00",
    "duration_hours": 6,
    "pax": { "adults": 3 },
    "vehicle_preference": "van"
  }
}
```

### tour — activities / attractions / museum tickets · suppliers: Viator + Daytrip Day Trips + Amadeus + Tiqets · **LIVE (Viator+Daytrip); Amadeus/Tiqets pending keys**
**Locator:** provide **`geo` (+ optional `radius_km`, 1–20, default 5) and/or `city_code`** — `city_code` is **optional**. Required: `country_code`, `date`, `pax`.
- **Named museum / attraction / skip-the-line tickets** → send `geo` + `name` (e.g. "British Museum"). These route to **Amadeus** (geo-based ticketing) — which needs `AMADEUS_API_KEY` (**pending**); Tiqets (also tickets) is city-based.
- **City activities / day-trips** → send `city_code` + `country_code`. The city-based suppliers (Viator, Daytrip, Tiqets) need `city_code` and **abstain (`no_coverage`) without it** — so a geo-only request with no enabled geo supplier returns `no_coverage`.
```json
{
  "tour_id": "t1", "source": "itinerary_builder", "service_type": "tour",
  "tour": {
    "geo": { "latitude": 51.5194, "longitude": -0.127 }, "radius_km": 5,
    "country_code": "GB", "date": "2026-07-10",
    "name": "British Museum",
    "pax": { "adults": 2 }, "language": "en"
  },
  "nova_service_id": "item:…"
}
```
Notes: **`name` narrows the result to that specific activity.** When you send a `name` with a distinctive word (e.g. "Louvre", "Giverny", "Versailles"), each supplier only quotes products whose title matches it and **abstains (`no_coverage`) if none match** — so a named item is never mis-priced as an unrelated cheapest-in-city excursion. A name of only generic words ("Private Walking Tour") imposes no filter, and **omitting `name` returns the single cheapest experience** (back-compat). `interests` is accepted but not yet used for filtering. The response echoes `requested_service_type` (your Luna code, e.g. `ATR`/`GUI`/`ENT`) + `nova_service_id`; the ranked candidate list is in `raw_supplier_payload`.

### train — point-to-point rail · supplier: Rail Europe (ERA) · **pending credentials**
**Gotcha:** free-text station names resolve via supplier search; passing UIC codes (`origin_station_uic`/`dest_station_uic`) is more reliable. `return_at` makes it round-trip.
```json
{
  "tour_id": "t1", "source": "itinerary_builder", "service_type": "train",
  "train": {
    "origin_station": "Paris Gare du Nord", "dest_station": "London St Pancras",
    "depart_at": "2026-05-18T10:13:00+02:00",
    "pax": { "adults": 2 }, "class": "standard"   // standard|first|business
  }
}
```

### car_hire — car rental · supplier: AutoEurope · **demo-fixtures only (no live API yet)**
Sendable, but only returns prices for pre-captured demo routes; the live API is not built. `pickup_location`/`return_location` prefer IATA codes.
```json
{
  "tour_id": "t1", "source": "itinerary_builder", "service_type": "car_hire",
  "car_hire": {
    "pickup_location": "LHR", "return_location": "LHR",
    "pickup_at": "2026-06-12T09:00:00Z", "return_at": "2026-06-19T18:00:00Z",
    "driver_age": 35, "vehicle_category": "economy"
  }
}
```

### restaurant — priced dining experiences · supplier: TheFork · **pending partner API**
Vertical + contract are live; the supplier call is pending TheFork partner access. Focus is **priced** experiences (set/tasting menus, chef's table, events). **Located by `geo` (+ optional `radius_km`) and/or `city_code`** (`city_code` optional); required: `country_code`, `date`, `party_size`. `name` targets a specific venue.
```json
{
  "tour_id": "t1", "source": "itinerary_builder", "service_type": "restaurant",
  "restaurant": {
    "geo": { "latitude": 51.5212, "longitude": -0.155 }, "radius_km": 5,
    "country_code": "GB", "date": "2026-07-10", "time": "20:00",
    "party_size": 6, "name": "Local Marylebone Gastropub",
    "experience_type": "any"   // standard|set_menu|tasting_menu|chefs_table|event|any
  },
  "nova_service_id": "…"
}
```

### ferry — **not available** (`service_type: "ferry"` validates but has no supplier → `no_suppliers_enabled`).

---

## 6. The response — `service_prices`

For a non-hotel resolution, the result lives in `service_prices` (on the `ResolutionDetail`, and in the `reconciled`/`no_supplier` webhook payload). The detail + webhook also echo back **`requested_service_type`** (your original Luna code) and **`nova_service_id`** so you can correlate the answer to your request.

```jsonc
{
  "service_type": "transfer",
  "flow_kind": "service",
  "service_prices": {
    "vertical": "transfer",
    "suppliers_attempted": ["daytrip"],     // all enabled suppliers we called
    "suppliers_returned":  ["daytrip"],     // those that returned a usable quote
    "quotes": [ /* every ServiceQuote returned */ ],
    "cheapest": {                            // the pick — null when nothing quoted
      "supplier": "daytrip",
      "vertical": "transfer",
      "total_price": 142.50,                 // total for the whole service window
      "currency": "EUR",
      "refundable": true,
      "cancellation_policy": "Free cancellation up to 24h before departure",
      "attributes": { /* vertical-specific: vehicle_type, duration_minutes, product_title, … */ },
      "rate_key": "<opaque handle — pass back to book through the same supplier>",
      "fetched_at": "2026-05-15T14:30:02Z"
    },
    // present only when cheapest === null:
    "reason": "no_inventory",
    "reason_detail": "human-readable explanation you can surface to the agent",
    "per_supplier_failures": [ { "supplier": "daytrip", "kind": "no_inventory", "detail": "…" } ]
  }
}
```

**Failure reasons** (`reason` / `per_supplier_failures[].kind`):
| Reason | Meaning / action |
|---|---|
| `no_inventory` | supplier reachable, no availability → try alternate dates/options |
| `no_matching_option` | inventory exists but not for the requested class/vehicle/etc → relax filters |
| `no_coverage` | supplier can't serve the request as given → the geo/iata gotcha, a city not in the supplier's catalogue, a named activity not found, or a party too large for one vehicle (transfer 8+). The `detail` says which; retrying as-is won't help. |
| `supplier_api_error` | network/auth/HTTP issue → retry later |
| `no_suppliers_enabled` | no supplier configured for this vertical (e.g. ferry) |
| `mixed_failures` | suppliers failed for different reasons → see `per_supplier_failures` |

---

## 7. Booking

Today everything is **quote-only.** Each quote carries a `rate_key` (an opaque supplier handle) — book with the supplier directly using it, without re-searching. If/when Voyager decides PRS should book on your behalf, the contract already has a booking seam per supplier (currently exposed for the `restaurant` vertical); a `POST /v1/bookings`-style route would be the only addition. Talk to us before relying on this.

---

## 8. Live-status matrix (as of this writing)

| Vertical | Supplier(s) | Quotes today? |
|---|---|---|
| transfer | Daytrip | ✅ yes (sandbox inventory) |
| chauffeur | Daytrip | ✅ yes (sandbox inventory) |
| tour | Viator, Daytrip | ✅ yes · Amadeus + Tiqets pending keys |
| car_hire | AutoEurope | ⚠️ demo-fixtures only |
| train | Rail Europe | ⛔ pending credentials |
| restaurant | TheFork | ⛔ pending partner API |
| ferry | — | ⛔ no supplier |

All inventory suppliers currently run on **staging/sandbox** credentials, so availability is thin — treat sandbox quotes as integration proof, not production pricing.

---

## 9. Gotchas checklist
1. **`Idempotency-Key` on every POST** (400 without it).
2. **Timestamps carry a timezone offset; dates are `YYYY-MM-DD`.**
3. **Daytrip (transfer/chauffeur) needs `geo` or `iata_code`** — address-only = no quote.
4. **tour needs a resolvable `city_code` + correct `country_code`**; pass `geo`/`radius_km` for the best museum/ticket results.
5. **Use `?wait=true`** for synchronous itinerary building; use `webhook_url` for fire-and-forget.
6. A `no_supplier` result is a normal "no availability," not an error — render it as "source manually," not a failure.
