{"openapi":"3.0.0","info":{"title":"Purple Expense API","description":"Purple Expense — travel & expense management. Search bookable flight and hotel inventory, book trips, capture receipts, submit expense reports, and run approvals. Resembles tools like SAP Concur, Navan, and Expensify.\n\n---\n\n**Acting as a user (`X-PS-Impersonate-User`).** Every endpoint accepts an optional `X-PS-Impersonate-User` request header whose value is the email of the instance user to act as. The effective user resolves in this order: the header if present, else the instance's configured default MCP user, else none. User-scoped paths additionally accept the literal `me` in place of an email, and Graph-style apps also serve `/me/…` twins of their `/users/{email}/…` paths that resolve the same way. This header is honored by all operations but is intentionally omitted from each operation's parameter list so it is not surfaced as a per-tool argument by MCP clients; see the `ImpersonateUser` entry under `components.parameters`.","version":"1.0.0"},"servers":[{"url":"https://marketplace.moveworks.com/api/purple-suite/expense"}],"security":[{"patAuth":[]}],"components":{"securitySchemes":{"patAuth":{"type":"http","scheme":"bearer"}},"parameters":{"InstanceId":{"in":"header","name":"X-Instance-ID","required":true,"schema":{"type":"string"}},"ImpersonateUser":{"in":"header","name":"X-PS-Impersonate-User","required":false,"schema":{"type":"string","format":"email"},"description":"Optional. Act as a specific user in the target instance — the value is that user's email (must exist in the instance). Resolution order for the effective user: (1) this header, if set; (2) the instance's configured default MCP user; (3) otherwise no user is bound. User-scoped paths (e.g. /users/{email}/…) also accept the literal \"me\", and Graph-style apps serve /me/… twins of those paths (Graph: /me ≡ /users/{signed-in-user}) — both resolve to whatever this header (or the default MCP user) points at. An explicit path email always wins over the header. The unified MCP server does not send this header — it relies on the per-instance default — so it is primarily for direct REST/HTTP callers."}},"schemas":{"Trip":{"type":"object","properties":{"id":{"type":"string","readOnly":true},"name":{"type":"string","example":"SFO Customer Visit","description":"Short label for the trip (e.g. 'SF Sales Summit 2026')"},"purpose":{"type":"string","example":"Q2 customer kickoff and partner meetings","description":"Business reason for the trip"},"traveler_email":{"type":"string","example":"alice@acme.com"},"start_date":{"type":"string","format":"date","example":"2026-06-10"},"end_date":{"type":"string","format":"date","example":"2026-06-13"},"origin":{"type":"string","example":"JFK","description":"Departure airport or city code"},"destination":{"type":"string","example":"SFO","description":"Primary destination airport or city code"},"destination_country":{"type":"string","example":"US","description":"ISO country of the destination (derived from the destination airport/city). Cross-reference with HRIS work authorizations to assess travel eligibility."},"is_international":{"type":"boolean","example":true,"description":"True when the destination country differs from the traveler's home country."},"status":{"type":"string","enum":["planned","approved","in_progress","completed","cancelled"],"example":"planned"},"estimated_cost":{"type":"number","example":2400},"actual_cost":{"type":"number","example":2185,"description":"Populated once the trip is in_progress or completed"},"currency":{"type":"string","example":"USD","default":"USD"},"cost_center":{"type":"string","example":"SALES-001"},"project_code":{"type":"string","example":"FY26-Q2-KICKOFF"},"approver_email":{"type":"string","example":"manager@acme.com"},"approved_by":{"type":"string","example":"manager@acme.com","readOnly":true},"approved_at":{"type":"string","format":"date-time","example":"2026-05-20T14:30:00.000Z","readOnly":true},"approval_notes":{"type":"string","example":"Within policy. Approved.","readOnly":true},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}},"Booking":{"type":"object","properties":{"id":{"type":"string","readOnly":true},"trip_id":{"type":"string","example":"TRIP-0001"},"booking_type":{"type":"string","enum":["flight","hotel","car","rail"],"example":"flight"},"flight_id":{"type":"string","example":"FLT-0001","description":"Set when this booking was made from a flight in the searchable inventory (see POST /flights/{id}/book)"},"hotel_id":{"type":"string","example":"HOT-0001","description":"Set when this booking was made from a hotel in the searchable inventory (see POST /hotels/{id}/book)"},"vendor":{"type":"string","example":"United","description":"Airline, hotel chain, car rental agency, or rail operator"},"confirmation_number":{"type":"string","example":"X4K9PQ"},"status":{"type":"string","enum":["confirmed","cancelled","completed"],"example":"confirmed"},"start_datetime":{"type":"string","format":"date-time","example":"2026-06-10T08:00:00Z"},"end_datetime":{"type":"string","format":"date-time","example":"2026-06-10T11:30:00Z"},"origin":{"type":"string","example":"JFK","description":"Departure airport/city/pickup location (flights only)"},"destination":{"type":"string","example":"SFO","description":"Arrival airport/city/dropoff location"},"fare_class":{"type":"string","example":"Economy","description":"Cabin class (Economy, Business) or room type (Standard King)"},"cost":{"type":"number","example":620},"currency":{"type":"string","example":"USD","default":"USD"},"traveler_email":{"type":"string","example":"alice@acme.com"},"notes":{"type":"string","example":""},"cancelled_at":{"type":"string","format":"date-time","readOnly":true},"cancelled_by":{"type":"string","readOnly":true},"cancellation_reason":{"type":"string","readOnly":true},"refund_amount":{"type":"number","readOnly":true},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}},"ExpenseReport":{"type":"object","properties":{"id":{"type":"string","readOnly":true},"name":{"type":"string","example":"Customer trip — May","description":"User-facing title (e.g. 'May client trip')"},"employee_email":{"type":"string","example":"alice@acme.com"},"trip_id":{"type":"string","example":"TRIP-0001","description":"Optional link to a Trip"},"status":{"type":"string","enum":["draft","submitted","approved","rejected","reimbursed"],"example":"draft"},"total_amount":{"type":"number","example":1842.5,"description":"Server-maintained running total of attached expense items; recomputed on every item create/update/delete. Values sent by clients are ignored","readOnly":true},"currency":{"type":"string","example":"USD","default":"USD"},"cost_center":{"type":"string","example":"SALES-001"},"project_code":{"type":"string","example":"FY26-Q2-KICKOFF"},"approver_email":{"type":"string","example":"manager@acme.com"},"submitted_at":{"type":"string","format":"date-time","readOnly":true},"submitted_by":{"type":"string","readOnly":true},"approved_at":{"type":"string","format":"date-time","readOnly":true},"approved_by":{"type":"string","readOnly":true},"approver_notes":{"type":"string","readOnly":true},"rejected_at":{"type":"string","format":"date-time","readOnly":true},"rejected_by":{"type":"string","readOnly":true},"rejection_reason":{"type":"string","readOnly":true},"reimbursed_at":{"type":"string","format":"date-time","readOnly":true},"reimbursement_method":{"type":"string","readOnly":true},"reimbursement_reference":{"type":"string","readOnly":true},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}},"ExpenseItem":{"type":"object","properties":{"id":{"type":"string","readOnly":true},"report_id":{"type":"string","example":"RPT-0001"},"trip_id":{"type":"string","example":"TRIP-0001"},"category":{"type":"string","enum":["airfare","lodging","ground_transportation","meals","entertainment","mileage","parking","tolls","phone","wifi","office_supplies","conference","training","other"],"example":"meals"},"merchant":{"type":"string","example":"Sweetgreen"},"description":{"type":"string","example":"Team dinner"},"transaction_date":{"type":"string","format":"date","example":"2026-06-11"},"amount":{"type":"number","example":87.5},"currency":{"type":"string","example":"USD","default":"USD"},"exchange_rate":{"type":"number","example":1,"description":"Rate to base currency (USD); 1.0 for domestic"},"amount_in_base_currency":{"type":"number","example":87.5},"payment_method":{"type":"string","enum":["corporate_card","personal_card","cash","out_of_pocket"],"example":"corporate_card"},"receipt_url":{"type":"string","example":"https://receipts.purple-suite.example/abc123.pdf","description":"Link to stored receipt image or PDF"},"status":{"type":"string","enum":["pending","approved","rejected","flagged_policy"],"example":"pending"},"policy_violation":{"type":"string","example":"Exceeded daily meal cap","description":"Set when status is flagged_policy"},"is_billable":{"type":"boolean","example":false,"description":"Whether to bill back to a client"},"billable_client":{"type":"string","example":"Acme Corp"},"notes":{"type":"string","example":""},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}},"ExpensePolicy":{"type":"object","properties":{"id":{"type":"string","readOnly":true},"category":{"type":"string","enum":["airfare","lodging","ground_transportation","meals","entertainment","mileage","parking","tolls","phone","wifi","office_supplies","conference","training","other"],"example":"meals"},"daily_limit":{"type":"number","example":90,"description":"Maximum spend per day for this category (null if not applicable)"},"per_transaction_limit":{"type":"number","example":500,"description":"Maximum per single transaction (null if not applicable)"},"requires_receipt_above":{"type":"number","example":25,"description":"Dollar threshold above which a receipt is required"},"requires_pre_approval_above":{"type":"number","example":500,"description":"Dollar threshold above which pre-approval is required (null if not applicable)"},"allowed_payment_methods":{"type":"array","items":{"type":"string","enum":["corporate_card","personal_card","cash","out_of_pocket"]},"example":["corporate_card","personal_card","out_of_pocket"]},"mileage_rate":{"type":"number","example":0.67,"description":"Per-distance reimbursement rate in the instance's base currency (e.g. IRS $0.67/mi for a US-primary instance, ₹12/km for IN); only relevant for category=mileage"},"mileage_unit":{"type":"string","enum":["km","mi"],"example":"mi","description":"Distance unit the mileage_rate is per; set by the primary locale pack. Only present for category=mileage"},"currency":{"type":"string","example":"USD","default":"USD"},"effective_date":{"type":"string","format":"date","example":"2026-01-01"},"notes":{"type":"string","example":""},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}},"Approval":{"type":"object","properties":{"id":{"type":"string","readOnly":true},"target_type":{"type":"string","enum":["trip","expense_report"],"example":"expense_report","description":"The kind of record being approved"},"target_id":{"type":"string","example":"RPT-0001"},"requester_email":{"type":"string","example":"alice@acme.com"},"approver_email":{"type":"string","example":"manager@acme.com"},"status":{"type":"string","enum":["pending","approved","rejected"],"example":"pending"},"amount":{"type":"number","example":1842.5},"currency":{"type":"string","example":"USD","default":"USD"},"requested_at":{"type":"string","format":"date-time","example":"2026-05-25T09:00:00.000Z"},"decided_at":{"type":"string","format":"date-time","example":"2026-05-26T11:15:00.000Z"},"decision_notes":{"type":"string","example":"Within policy"},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}},"Airline":{"type":"object","properties":{"id":{"type":"string","readOnly":true},"code":{"type":"string","example":"UA","description":"IATA carrier code"},"name":{"type":"string","example":"United Airlines"},"alliance":{"type":"string","example":"Star Alliance","description":"Airline alliance, or 'None'"},"hubs":{"type":"array","items":{"type":"string"},"example":["SFO","ORD","EWR"],"description":"Primary hub airport codes"},"logo_url":{"type":"string","example":"https://logos.purple-suite.example/ua.png"},"on_time_rate":{"type":"number","example":0.82,"description":"Historical on-time arrival rate (0–1)"},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}},"Flight":{"type":"object","description":"A scheduled flight in the bookable inventory. Search with GET /flights/search, then book with POST /flights/{id}/book.","properties":{"id":{"type":"string","readOnly":true},"flight_number":{"type":"string","example":"UA 523"},"airline_code":{"type":"string","example":"UA"},"airline_name":{"type":"string","example":"United Airlines"},"origin":{"type":"string","example":"JFK","description":"Departure airport code"},"destination":{"type":"string","example":"SFO","description":"Arrival airport code"},"departure_datetime":{"type":"string","format":"date-time","example":"2026-06-10T08:00:00Z"},"arrival_datetime":{"type":"string","format":"date-time","example":"2026-06-10T11:30:00Z"},"duration_minutes":{"type":"integer","example":390},"stops":{"type":"integer","example":0,"description":"Number of layovers (0 = nonstop)"},"aircraft":{"type":"string","example":"Boeing 737-900"},"status":{"type":"string","enum":["scheduled","delayed","cancelled","departed","landed"],"example":"scheduled"},"fares":{"type":"array","description":"Available cabins with price and remaining seats. Booking a cabin decrements its seats_available.","items":{"type":"object","properties":{"cabin":{"type":"string","enum":["Economy","Premium Economy","Business","First"],"example":"Economy"},"price":{"type":"number","example":459},"seats_available":{"type":"integer","example":12},"refundable":{"type":"boolean","example":false}}}},"currency":{"type":"string","example":"USD","default":"USD"},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}},"Hotel":{"type":"object","description":"A bookable hotel in the inventory. Search with GET /hotels/search, then book with POST /hotels/{id}/book.","properties":{"id":{"type":"string","readOnly":true},"name":{"type":"string","example":"Marriott Marquis San Francisco"},"brand":{"type":"string","example":"Marriott"},"city":{"type":"string","example":"San Francisco"},"city_code":{"type":"string","example":"SFO","description":"Airport/city code, aligned with trip destinations"},"address":{"type":"string","example":"780 Mission St, San Francisco, CA"},"star_rating":{"type":"number","example":4,"description":"Star class, 1–5"},"guest_rating":{"type":"number","example":8.6,"description":"Average guest review score, 0–10"},"nightly_rate":{"type":"number","example":289,"description":"Base nightly rate; room_types may override"},"currency":{"type":"string","example":"USD","default":"USD"},"rooms_available":{"type":"integer","example":14,"description":"Bookable rooms remaining; booking decrements this"},"distance_to_city_center_km":{"type":"number","example":1.2},"amenities":{"type":"array","items":{"type":"string"},"example":["WiFi","Gym","Breakfast","Parking"]},"room_types":{"type":"array","description":"Optional room tiers with their own nightly rates","items":{"type":"object","properties":{"name":{"type":"string","example":"Standard King"},"rate":{"type":"number","example":289},"available":{"type":"integer","example":6}}}},"status":{"type":"string","enum":["active","inactive"],"example":"active"},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}},"TravelPolicy":{"type":"object","description":"Fare-class eligibility rule: the minimum seniority a traveler must hold to book a given cabin. Enforced by POST /flights/{id}/book — a traveler below the cabin's min_seniority is rejected with 403. Seniority is resolved from the employee's title (see HRIS).","properties":{"id":{"type":"string","readOnly":true},"cabin_class":{"type":"string","enum":["Economy","Premium Economy","Business","First"],"example":"Business","description":"The flight cabin this rule governs"},"min_seniority":{"type":"string","enum":["Individual Contributor","Manager","Senior Manager","Director","VP"],"example":"VP","description":"Minimum seniority level permitted to book this cabin. Levels, lowest to highest: Individual Contributor, Manager, Senior Manager, Director, VP."},"description":{"type":"string","example":"Business class is reserved for VP level and above."},"effective_date":{"type":"string","format":"date","example":"2026-01-01"},"notes":{"type":"string","example":""},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}},"PerDiemRate":{"type":"object","description":"Daily travel allowance for a destination: max lodging plus M&IE (meals & incidental expenses), GSA-style. Query the list, or use GET /per_diem_rates/calculate to total it over a number of days.","properties":{"id":{"type":"string","readOnly":true},"city":{"type":"string","example":"San Francisco"},"city_code":{"type":"string","example":"SFO","description":"Airport/city code, aligned with trip destinations and hotel city_code"},"country":{"type":"string","example":"US","description":"ISO country code"},"lodging_rate":{"type":"number","example":325,"description":"Maximum reimbursable lodging per night"},"mie_rate":{"type":"number","example":79,"description":"Meals & incidental expenses allowance per day"},"total_daily_rate":{"type":"number","example":404,"description":"lodging_rate + mie_rate — the full per diem per day"},"currency":{"type":"string","example":"USD","default":"USD","description":"The instance's base currency. Rates for foreign destinations are FX-converted into it at seed time, so they compare directly with trip and expense amounts (which carry the same currency)."},"effective_date":{"type":"string","format":"date","example":"2026-01-01"},"notes":{"type":"string","example":""},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}}}},"paths":{"/trips":{"get":{"summary":"List business trips","description":"A trip is the travel-request container in Purple Expense: traveler, dates, origin/destination, purpose, cost centre and approval state. Bookings hang off a trip via trip_id and an expense report can link to one via trip_id. Use this to find a traveler's upcoming or past travel; use bookings for the individual flight/hotel reservations. Server-side query: $filter/$select/$orderby/$search/$top/$skip/$count (MCP clients pass these as _filter, _top, … because $ is not a legal tool-input key).","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Trip"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Create a business trip","description":"Opens a new trip in `planned` status for a traveler. Create the trip first when you want to group bookings and expenses under one itinerary — note that booking a flight or hotel without a trip_id auto-creates one for you.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Trip"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Trip"}}}}}}},"/trips/{id}":{"get":{"summary":"Get a business trip by id","description":"Returns one trip with its itinerary, cost fields and approval stamps (approved_by/at). Bookings and expense items are separate records that reference this trip's id.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Trip"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Edit a business trip","description":"Partial update of trip details (dates, destination, purpose, cost centre, estimated cost). For the planned → approved transition use the dedicated approve action instead of setting `status` here.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Trip"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Trip"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete a business trip","description":"DESTRUCTIVE and irreversible: removes the trip record. Bookings and expense items that referenced it are left pointing at a missing trip. Prefer setting status to `cancelled` unless the user really wants it gone.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/trips/{id}/approve":{"post":{"summary":"Pre-approve a trip","description":"Manager pre-approval of the TRIP itself (the travel request), not of any spend. Transitions a trip from planned to approved and stamps approved_by/approved_at. Cannot approve a trip that is already approved, in_progress, completed or cancelled. Reimbursement of the money is a separate flow on expense reports.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"approved_by":{"type":"string","example":"manager@acme.com"},"approval_notes":{"type":"string","example":"Within policy. Approved."}},"required":["approved_by"]}}}},"responses":{"200":{"description":"Trip approved","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Trip"}}}},"404":{"description":"Trip not found"},"422":{"description":"Trip cannot be approved in its current state"}}}},"/bookings":{"get":{"summary":"List travel bookings (reservations)","description":"A booking is one confirmed reservation — flight, hotel, car or rail — with vendor, confirmation number, cost and start/end datetimes, usually attached to a trip via trip_id and, when made from inventory, to the source flight_id/hotel_id. Bookings are what the traveler actually holds; flights and hotels are the bookable catalogue. Server-side query: $filter/$select/$orderby/$search/$top/$skip/$count (MCP clients pass these as _filter, _top, … because $ is not a legal tool-input key).","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Booking"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Record a travel booking manually","description":"Creates a booking row for a reservation made outside the inventory (an existing confirmation number, a car or rail segment). It does NOT touch flight/hotel availability — to book from the catalogue and decrement seats/rooms, use the flight or hotel book action instead.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Booking"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Booking"}}}}}}},"/bookings/{id}":{"get":{"summary":"Get a travel booking by id","description":"Returns one reservation with its vendor, confirmation number, cost, cabin/room class and, if cancelled, the cancellation stamps and refund amount.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Booking"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Edit a travel booking","description":"Partial update of reservation details (times, vendor, confirmation number, cost, traveler). To cancel, use the dedicated cancel action rather than setting `status` here — cancel is what writes the cancellation stamps and refund.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Booking"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Booking"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete a travel booking","description":"DESTRUCTIVE and irreversible: erases the reservation record entirely, with no refund and no release of inventory. Use the cancel action instead for a normal traveler-facing cancellation.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/bookings/{id}/cancel":{"post":{"summary":"Cancel a booking","description":"The traveler-facing way to cancel a reservation: marks the booking cancelled and records who cancelled it, why, and the refund amount. Cannot cancel a booking that is already cancelled or completed. Prefer this over deleting the booking, which erases the record and issues no refund.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"cancelled_by":{"type":"string","example":"alice@acme.com"},"cancellation_reason":{"type":"string","example":"Trip rescheduled"},"refund_amount":{"type":"number","example":434}},"required":["cancelled_by"]}}}},"responses":{"200":{"description":"Booking cancelled","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Booking"}}}},"404":{"description":"Booking not found"},"422":{"description":"Booking cannot be cancelled in its current state"}}}},"/expense_reports":{"get":{"summary":"List expense reports","description":"An expense report is the reimbursement envelope an employee submits: a named batch of expense items with an employee_email, optional trip_id, cost centre, approver and a status of draft → submitted → approved/rejected → reimbursed. `total_amount` is server-maintained from the attached items. Use this to answer 'what have I claimed / what is awaiting my approval'; use expense items for individual receipts. Server-side query: $filter/$select/$orderby/$search/$top/$skip/$count (MCP clients pass these as _filter, _top, … because $ is not a legal tool-input key).","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ExpenseReport"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Create a draft expense report","description":"Opens a new report in `draft` for an employee, optionally linked to a trip. Add expense items to it (they carry report_id) and then call the submit action — do not set total_amount, it is computed.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseReport"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseReport"}}}}}}},"/expense_reports/{id}":{"get":{"summary":"Get an expense report by id","description":"Returns one report with its status, server-computed total and the full audit stamps for each transition (submitted_by/at, approved_by/at, rejection_reason, reimbursed_at, reimbursement_reference).","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseReport"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Edit an expense report's details","description":"Partial update of report metadata (name, cost centre, project code, approver). Use the dedicated submit / approve / reject / reimburse actions to move `status` — they enforce the state machine and write the audit stamps. `total_amount` is server-maintained and ignored on input.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseReport"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseReport"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete an expense report","description":"DESTRUCTIVE and irreversible: removes the report, including any approval and reimbursement history. Expense items that referenced it are orphaned. Confirm with the user before calling.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/expense_reports/{id}/submit":{"post":{"summary":"Submit a draft expense report for approval","description":"Step 1 of the reimbursement chain (submit → approve → reimburse). The EMPLOYEE sends their draft report to its approver: transitions draft → submitted and stamps submitted_by/at. Only draft reports can be submitted; use approve for the manager's decision.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"submitted_by":{"type":"string","example":"alice@acme.com","description":"Defaults to employee_email if omitted"}}}}}},"responses":{"200":{"description":"Report submitted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseReport"}}}},"404":{"description":"Report not found"},"422":{"description":"Report is not in draft status"}}}},"/expense_reports/{id}/approve":{"post":{"summary":"Approve a submitted expense report","description":"Step 2 of the reimbursement chain: the APPROVER signs off on a submitted report, transitioning submitted → approved with approved_by/at and optional approver_notes. Approving does not pay the employee — call the reimburse action for that. Only submitted reports can be approved; use reject to decline one.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"approved_by":{"type":"string","example":"manager@acme.com"},"approver_notes":{"type":"string","example":"Within policy. Approved."}},"required":["approved_by"]}}}},"responses":{"200":{"description":"Report approved","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseReport"}}}},"404":{"description":"Report not found"},"422":{"description":"Report is not in submitted status"}}}},"/expense_reports/{id}/reject":{"post":{"summary":"Reject a submitted expense report","description":"The approver's decline decision, the counterpart of approve: transitions submitted → rejected with rejected_by/at. `rejection_reason` is REQUIRED and is shown back to the employee. Only submitted reports can be rejected.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"rejected_by":{"type":"string","example":"manager@acme.com"},"rejection_reason":{"type":"string","example":"Receipt missing for >$75 item"}},"required":["rejected_by","rejection_reason"]}}}},"responses":{"200":{"description":"Report rejected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseReport"}}}},"404":{"description":"Report not found"},"422":{"description":"Report is not in submitted status, or rejection_reason is missing"}}}},"/expense_reports/{id}/reimburse":{"post":{"summary":"Mark an approved expense report as paid out","description":"Final step of the reimbursement chain, normally performed by finance: transitions approved → reimbursed and records reimbursed_at, reimbursement_method (defaults to direct_deposit) and a payment reference. This is the money-moving step and is terminal — only approved reports qualify, and approve alone does not pay.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"reimbursement_method":{"type":"string","enum":["direct_deposit","check","wire"],"example":"direct_deposit"},"reimbursement_reference":{"type":"string","example":"REIM-AB12CD"}}}}}},"responses":{"200":{"description":"Report reimbursed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseReport"}}}},"404":{"description":"Report not found"},"422":{"description":"Report is not in approved status"}}}},"/expense_items":{"get":{"summary":"List expense line items (receipts)","description":"An expense item is a single spend line — one receipt: merchant, category, transaction_date, amount (plus FX to base currency), payment method, receipt_url and a status that can be flagged_policy with a policy_violation note. Items attach to an expense report via report_id and optionally a trip via trip_id. Use this for individual charges; use expense reports for the submitted envelope. Server-side query: $filter/$select/$orderby/$search/$top/$skip/$count (MCP clients pass these as _filter, _top, … because $ is not a legal tool-input key).","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ExpenseItem"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Add an expense line item to a report","description":"Records one charge against the report named in `report_id` (and optionally a trip). Creating an item recomputes the parent report's total_amount. Categories and limits are governed by the expense_policies collection.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseItem"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseItem"}}}}}}},"/expense_items/{id}":{"get":{"summary":"Get an expense line item by id","description":"Returns one spend line with its amount, currency conversion, receipt link, billability and any policy violation recorded against it.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseItem"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Edit an expense line item","description":"Partial update of one charge (amount, category, merchant, receipt_url, billability). Changing the amount recomputes the parent report's total_amount.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseItem"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpenseItem"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Remove an expense line item from a report","description":"DESTRUCTIVE and irreversible: deletes the receipt line and recomputes the parent report's total_amount downward. There is no undo.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/expense_policies":{"get":{"summary":"List spend policy rules by expense category","description":"An expense policy is the SPEND rule for one category (meals, lodging, mileage, …): daily_limit, per_transaction_limit, the receipt threshold, the pre-approval threshold, allowed payment methods and the mileage rate/unit. Read these to answer 'is this claim within policy' or 'what is the mileage rate'. Distinct from travel_policies, which govern flight cabin eligibility, and per_diem_rates, which are destination allowances. Server-side query: $filter/$select/$orderby/$search/$top/$skip/$count (MCP clients pass these as _filter, _top, … because $ is not a legal tool-input key).","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ExpensePolicy"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Create a spend policy rule for a category","description":"Defines the limits and receipt/pre-approval thresholds for one expense category. Administrative configuration — it changes what counts as a violation for future claims.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpensePolicy"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpensePolicy"}}}}}}},"/expense_policies/{id}":{"get":{"summary":"Get a spend policy rule by id","description":"Returns one category's limits, thresholds, allowed payment methods and effective date.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpensePolicy"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Edit a spend policy rule","description":"Partial update of a category's limits or thresholds. Administrative change that alters what is flagged as a policy violation going forward; it does not re-evaluate existing expense items.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpensePolicy"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExpensePolicy"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete a spend policy rule","description":"DESTRUCTIVE and irreversible: removes the category's limits entirely, so that category becomes unconstrained. Confirm with the user before calling.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/approvals":{"get":{"summary":"List approval requests (trips and expense reports)","description":"An approval is the routing/audit record for one decision: target_type (trip or expense_report) plus target_id, the requester, the assigned approver, amount, status and decision notes. Query it to build an approver's queue across both trips and reports. The decision itself is made with the trip approve or expense-report approve/reject actions, not by editing this record. Server-side query: $filter/$select/$orderby/$search/$top/$skip/$count (MCP clients pass these as _filter, _top, … because $ is not a legal tool-input key).","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Approval"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Raise an approval request","description":"Creates a routing record asking a named approver to decide on a trip or expense report. It records the request only; it does not itself change the target record's status.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Approval"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Approval"}}}}}}},"/approvals/{id}":{"get":{"summary":"Get an approval request by id","description":"Returns one approval record — what it targets, who must decide, the amount, and the decision and notes if made.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Approval"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Edit an approval request","description":"Partial update of the routing record (reassign the approver, note a decision). Prefer the trip approve or expense-report approve/reject/reimburse actions to actually move the target through its state machine.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Approval"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Approval"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete an approval request","description":"DESTRUCTIVE and irreversible: removes the approval record and its decision history. The target trip or report is left unchanged.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/airlines":{"get":{"summary":"List airline carriers (reference data)","description":"An airline is reference data about a carrier — IATA code, name, alliance, hub airports and historical on-time rate. Use it to resolve or describe the airline_code on a flight; it holds no schedules or availability, which live on flights. Server-side query: $filter/$select/$orderby/$search/$top/$skip/$count (MCP clients pass these as _filter, _top, … because $ is not a legal tool-input key).","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Airline"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Add an airline carrier","description":"Adds a carrier to the reference list (code, name, alliance, hubs). Seed/admin data — it does not create any bookable flights.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Airline"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Airline"}}}}}}},"/airlines/{id}":{"get":{"summary":"Get an airline carrier by id","description":"Returns one carrier's reference details: IATA code, name, alliance, hubs and on-time rate.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Airline"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Edit an airline carrier","description":"Partial update of carrier reference data. Does not affect flights already referencing the carrier code.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Airline"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Airline"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete an airline carrier","description":"DESTRUCTIVE and irreversible: removes the carrier from reference data. Flights keep their airline_code but can no longer be resolved to a carrier.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/flights/search":{"get":{"x-odata":false,"summary":"Search bookable flights","description":"The shopping entry point for air travel — prefer this over listing flights. Filters the inventory by route, date, cabin and price, returns only flights with at least one matching fare that still has seats available, cheapest fare first. Read-only: pass a returned flight's id to the flight book action to actually reserve a seat.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"origin","schema":{"type":"string"},"example":"JFK","description":"Departure airport code"},{"in":"query","name":"destination","schema":{"type":"string"},"example":"SFO","description":"Arrival airport code"},{"in":"query","name":"date","schema":{"type":"string","format":"date"},"example":"2026-06-10","description":"Departure date (YYYY-MM-DD)"},{"in":"query","name":"cabin","schema":{"type":"string","enum":["Economy","Premium Economy","Business","First"]},"description":"Only return flights offering this cabin with seats available"},{"in":"query","name":"max_price","schema":{"type":"number"},"description":"Only return flights with a matching fare at or below this price"},{"in":"query","name":"limit","schema":{"type":"integer","default":20}}],"responses":{"200":{"description":"Matching flights","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Flight"}},"total":{"type":"integer"}}}}}}}}},"/flights":{"get":{"summary":"List the raw flight inventory","description":"A flight is one scheduled segment in the bookable catalogue: number, carrier, origin/destination, times, stops, status and a `fares` array of cabins with price and seats_available. This is the unfiltered catalogue dump — to shop for travel prefer the flight search operation, which filters by route/date/cabin/price and returns only flights with seats left, cheapest first. Server-side query: $filter/$select/$orderby/$search/$top/$skip/$count (MCP clients pass these as _filter, _top, … because $ is not a legal tool-input key).","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Flight"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Add a flight to the inventory","description":"Seeds a new bookable segment with its cabins/fares. Inventory administration — this is not how a traveler books; use the flight book action.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Flight"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Flight"}}}}}}},"/flights/{id}":{"get":{"summary":"Get a flight by id","description":"Returns one segment with its schedule and live per-cabin fares/seats_available. Use before booking to confirm the cabin and price.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Flight"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Edit a flight in the inventory","description":"Partial update of a segment's schedule, status or fares. Inventory administration — booking already decrements seats_available for you.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Flight"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Flight"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete a flight from the inventory","description":"DESTRUCTIVE and irreversible: removes the segment from the catalogue. Existing bookings keep their flight_id but it no longer resolves.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/flights/{id}/book":{"post":{"summary":"Book a flight","description":"Reserves a seat in the given cabin and creates a confirmed booking that links back to this flight via flight_id. Decrements the cabin's seats_available. Fails if the cabin is sold out or the flight is cancelled. Premium cabins are subject to fare-class policy (see the travel_policies collection): if the cabin's min_seniority exceeds the traveler's level, the booking is rejected with 403. If no trip_id is supplied, a trip is auto-created from the flight's route/dates and the booking is attached to it.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"traveler_email":{"type":"string","example":"alice@acme.com"},"cabin":{"type":"string","enum":["Economy","Premium Economy","Business","First"],"example":"Economy","description":"Defaults to Economy"},"trip_id":{"type":"string","example":"TRIP-0001","description":"Optional. Trip to attach the booking to; if omitted, a new trip is auto-created from the flight."},"confirmation_number":{"type":"string","description":"Optional; auto-generated if omitted"}},"required":["traveler_email"]}}}},"responses":{"201":{"description":"Flight booked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Booking"}}}},"403":{"description":"Traveler is not eligible for the requested cabin under fare-class policy"},"404":{"description":"Flight not found"},"422":{"description":"Cabin sold out, flight cancelled, or traveler_email missing"}}}},"/hotels/search":{"get":{"x-odata":false,"summary":"Search bookable hotels","description":"The shopping entry point for lodging — prefer this over listing hotels. Filters the inventory by city, nightly rate and star rating, returns only active properties with rooms available, cheapest first. Read-only: pass a returned hotel's id to the hotel book action to actually reserve a room.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"city","schema":{"type":"string"},"example":"SFO","description":"Matches city name, city_code, or address (case-insensitive substring)"},{"in":"query","name":"max_nightly_rate","schema":{"type":"number"},"description":"Only hotels at or below this nightly rate"},{"in":"query","name":"min_star_rating","schema":{"type":"number"},"description":"Only hotels at or above this star rating"},{"in":"query","name":"limit","schema":{"type":"integer","default":20}}],"responses":{"200":{"description":"Matching hotels","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Hotel"}},"total":{"type":"integer"}}}}}}}}},"/hotels":{"get":{"summary":"List the raw hotel inventory","description":"A hotel is one bookable property: name, brand, city/city_code, star and guest ratings, nightly_rate, amenities, room_types and rooms_available. This is the unfiltered catalogue dump — to shop for lodging prefer the hotel search operation, which filters by city, rate and star rating and returns only active properties with rooms left. Server-side query: $filter/$select/$orderby/$search/$top/$skip/$count (MCP clients pass these as _filter, _top, … because $ is not a legal tool-input key).","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Hotel"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Add a hotel to the inventory","description":"Seeds a new bookable property with its rates and room types. Inventory administration — travelers book via the hotel book action.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hotel"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hotel"}}}}}}},"/hotels/{id}":{"get":{"summary":"Get a hotel by id","description":"Returns one property with its rates, amenities, room tiers and current rooms_available. Use before booking to confirm rate and availability.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hotel"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Edit a hotel in the inventory","description":"Partial update of a property's rate, status, amenities or room types. Inventory administration — booking already decrements rooms_available.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hotel"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hotel"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete a hotel from the inventory","description":"DESTRUCTIVE and irreversible: removes the property from the catalogue. Existing bookings keep their hotel_id but it no longer resolves.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/hotels/{id}/book":{"post":{"summary":"Book a hotel stay","description":"Reserves a room for the given dates and creates a confirmed booking that links back to this hotel via hotel_id. Cost is the nightly rate × nights. Decrements rooms_available. If no trip_id is supplied, a trip is auto-created from the hotel's city/dates and the booking is attached to it.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"traveler_email":{"type":"string","example":"alice@acme.com"},"check_in":{"type":"string","format":"date","example":"2026-06-10"},"check_out":{"type":"string","format":"date","example":"2026-06-13"},"room_type":{"type":"string","example":"Standard King","description":"Optional; uses the matching room_types rate, else the base nightly_rate"},"trip_id":{"type":"string","example":"TRIP-0001","description":"Optional. Trip to attach the booking to; if omitted, a new trip is auto-created from the hotel stay."},"confirmation_number":{"type":"string","description":"Optional; auto-generated if omitted"}},"required":["traveler_email","check_in","check_out"]}}}},"responses":{"201":{"description":"Hotel booked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Booking"}}}},"404":{"description":"Hotel not found"},"422":{"description":"No rooms available, invalid dates, or required fields missing"}}}},"/travel_policies":{"get":{"summary":"List fare-class (cabin) eligibility rules","description":"A travel policy is a cabin-eligibility rule: the minimum seniority a traveler must hold to book a given cabin_class. It is enforced at booking time — the flight book action rejects an under-ranked traveler with 403. Read it to answer 'am I allowed to fly business'. Distinct from expense_policies (spend limits per category). Server-side query: $filter/$select/$orderby/$search/$top/$skip/$count (MCP clients pass these as _filter, _top, … because $ is not a legal tool-input key).","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TravelPolicy"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Create a fare-class eligibility rule","description":"Sets the minimum seniority required for a cabin class. Administrative — it takes effect on future flight bookings.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TravelPolicy"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TravelPolicy"}}}}}}},"/travel_policies/{id}":{"get":{"summary":"Get a fare-class eligibility rule by id","description":"Returns one cabin rule: the cabin_class it governs, its min_seniority and effective date.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TravelPolicy"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Edit a fare-class eligibility rule","description":"Partial update of a cabin's min_seniority or description. Changes who can book that cabin from now on; existing bookings are untouched.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TravelPolicy"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TravelPolicy"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete a fare-class eligibility rule","description":"DESTRUCTIVE and irreversible: removes the restriction, leaving that cabin bookable by anyone. Confirm with the user before calling.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}},"/per_diem_rates/calculate":{"get":{"x-odata":false,"summary":"Calculate per diem for a destination","description":"Looks up a city's per diem rate (by city_code or city name) and totals it over a number of days, returning lodging, M&IE and combined amounts. Use this whenever the question is 'how much allowance for N days in X'; use the per diem rates list only to browse the underlying rate table. Read-only — it creates nothing.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"city_code","schema":{"type":"string"},"example":"SFO","description":"Destination airport/city code (preferred). Provide this or city."},{"in":"query","name":"city","schema":{"type":"string"},"example":"San Francisco","description":"Destination city name (case-insensitive substring). Used if city_code is omitted."},{"in":"query","name":"days","schema":{"type":"integer","default":1},"example":3,"description":"Number of days to total the allowance over"}],"responses":{"200":{"description":"Per diem totals for the destination","content":{"application/json":{"schema":{"type":"object","properties":{"city":{"type":"string","example":"San Francisco"},"city_code":{"type":"string","example":"SFO"},"country":{"type":"string","example":"US"},"currency":{"type":"string","example":"USD"},"days":{"type":"integer","example":3},"lodging_rate":{"type":"number","example":325},"mie_rate":{"type":"number","example":79},"total_daily_rate":{"type":"number","example":404},"lodging_total":{"type":"number","example":975},"mie_total":{"type":"number","example":237},"total":{"type":"number","example":1212}}}}}},"404":{"description":"No per diem rate found for the destination"},"422":{"description":"Missing city_code/city, or days is not a positive integer"}}}},"/per_diem_rates":{"get":{"summary":"List per diem allowance rates by destination","description":"A per diem rate is a GSA-style daily travel allowance for a city: lodging_rate plus mie_rate (meals & incidentals) and their total, in the instance's base currency. Use this to browse or compare destinations; to total an allowance over a stay use the per diem calculate operation instead. Server-side query: $filter/$select/$orderby/$search/$top/$skip/$count (MCP clients pass these as _filter, _top, … because $ is not a legal tool-input key).","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PerDiemRate"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}}},"post":{"summary":"Create a per diem rate for a destination","description":"Adds a city's lodging and M&IE allowance. Administrative rate-table maintenance, not a traveler action.","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PerDiemRate"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PerDiemRate"}}}}}}},"/per_diem_rates/{id}":{"get":{"summary":"Get a per diem rate by id","description":"Returns one destination's lodging, M&IE and combined daily allowance with its effective date.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PerDiemRate"}}}},"404":{"description":"Not found"}}},"patch":{"summary":"Edit a per diem rate","description":"Partial update of a destination's lodging/M&IE allowance. Affects future per diem calculations.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PerDiemRate"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PerDiemRate"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete a per diem rate","description":"DESTRUCTIVE and irreversible: removes the destination's allowance, after which per diem calculation for that city will not resolve.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}}}}}}