Bookings
Создание, изменение, отмена и обмен бронирований.
Базовый префикс: /api/v1/bookings/
Создание
POST / — canonical quote-based
Создать бронирование. С 2026-06-01 канонический вход — только quote_id
(без legacy fields). Handler резолвит flight_id + fare_* из quote snapshot.
Body (canonical):
{
"quote_id": "abc123...",
"contact_email": "user@example.com",
"contact_name": "Иван Петров",
"contact_phone": "+996555000000",
"passengers": [{
"first_name": "JOHN",
"last_name": "DOE",
"passenger_type": "adult",
"date_of_birth": "1990-01-15",
"gender": "male",
"nationality": "KGZ",
"document_type": "passport",
"document_number": "AN1234567",
"document_expiry": "2030-05-01",
"seat_number": "12A"
}]
}Quote requirements: quote должен содержать
flight_id(route-based quote без flight_id не подходит для booking — 400). Делайте quote с явнымflight_idдля бронирования.
Три валидных варианта входа:
| Вариант | Поля | Когда |
|---|---|---|
| A. quote_id only (recommended) | quote_id + passengers + contact | Canonical путь — single source of truth pricing |
| B. segments[] | segments[] + passengers + contact | Multi-city explicit per-segment |
| C. Legacy | flight_id + fare_plan_id + fare_rule_id + fare_price_id + passengers | Backward-compat без quote |
Если квоты нет — сначала POST /api/v1/fares/quote, потом сюда с quote_id.
Бизнес-правила:
- Максимум 9 пассажиров на одну броню
expiry_date = created_at + 15 минут(для PENDING)total_amountберётся из quote (а не пересчитывается локально)
Validation: passenger_type vs age + document_expiry
С 2026-06-01 NurCore валидирует IATA Resolution 728 + ICAO Annex 9:
| Validation | Rule | Error при нарушении |
|---|---|---|
passenger_type=infant vs age | Возраст < 2 лет на дату вылета | «Тип 'infant' допустим только для детей до 2 лет на момент вылета (текущий возраст: X лет). Используйте 'child'.» |
passenger_type=child vs age | Возраст 2-11 лет на дату вылета | «Тип 'child' допустим для возраста 2-11 лет (текущий возраст: X лет)...» |
passenger_type=adult vs age | Возраст ≥ 12 лет | «Тип 'adult' требует возраст ≥ 12 лет...» |
document_expiry | > сегодня | «Документ просрочен» |
document_expiry vs departure_date | > date_of_flight | «Документ истекает 2026-08-01 — до даты вылета 2026-08-16. Обновите документ.» |
Возраст считается на дату вылета (от quote.flight_id либо booking_data.departure_date).
Many international destinations require passport validity ≥ 6 месяцев после travel date — это soft check на стороне carrier, не enforced у нас.
- Имена пассажиров — заглавными латинскими буквами (как в загранпаспорте)
Выбор места в салоне при бронировании
Каждый объект passengers[i] принимает опциональное поле seat_number
(формат ^\d{1,2}[A-Z]$, например 12A). Если место указано — backend
определяет его зону, списывает seat_fee из таблицы зон тарифа и
сохраняет в booking_seats. Если seat_number не указан — место будет
назначено автоматически при онлайн-регистрации.
Правило тарифа seat_selection
fare_rule.seat_selection управляет тем, можно ли выбрать место заранее.
seat_selection=true— выбор места при бронировании разрешён.seat_selection=false(типично у LIGHT-тарифов) —seat_numberвpassengers[]→ HTTP 422 с сообщением «Тариф не позволяет выбор места заранее». Место присваивается автоматически при регистрации.
Проверить флаг можно через GET /api/v1/fares/rules/{fare_rule_id} →
поле seat_selection.
Шаг 1. Карта салона (/checkin/flight/{id}/seat-map)
Тот же эндпоинт что и для онлайн-регистрации, но доступен и до
бронирования через X-API-Key с client_type=consumer_app
(без access_token/booking_id):
curl -H "X-API-Key: $KEY" \
"https://api.nurcore.kg/api/v1/checkin/flight/$FLIGHT_ID/seat-map"Response (сокращённо):
{
"flight_id": "…",
"aircraft_id": "…",
"columns": ["A", "B", "C", "D"],
"rows": [
{
"number": 1,
"class": "economy",
"seats": [
{"seat_number": "1A", "type": "standard", "status": "free", "fee": 0},
{"seat_number": "1B", "type": "standard", "status": "occupied", "fee": 0},
…
]
}
],
"stats": {"total": 80, "free": 78, "occupied": 2, "blocked": 0}
}status ∈ free | occupied | blocked — выбирайте только free.
Шаг 2. Цены зон мест (/fares/seat-prices/for-booking)
Зоны (стандарт / extra-legroom / front-row …) тарифицируются на уровне правила тарифа. Получить таблицу зон с ценами:
curl -H "X-API-Key: $KEY" \
"https://api.nurcore.kg/api/v1/fares/seat-prices/for-booking?fare_rule_id=$RULE"Response:
[
{"zone_code": "STD", "zone_name": "Стандартное место", "price": 0, "currency": "KGS", "is_included": true},
{"zone_code": "XL", "zone_name": "Место с увеличенным шагом", "price": 500, "currency": "KGS", "is_included": false},
{"zone_code": "FRONT", "zone_name": "Место в передних рядах", "price": 300, "currency": "KGS", "is_included": false}
]Зона конкретного места определяется на сервере по
seat_mapвоздушного судна (типы рядов: exit-row → XL, первые ряды → FRONT, остальное → STD). Клиент не передаётzone_code— толькоseat_number; сервер сам сопоставляет место → зону → цену.
Шаг 3. POST /bookings/ с seat_number
{
"flight_id": "…",
"fare_price_id": "…",
"quote_id": "…",
"passengers": [
{
"first_name": "JOHN", "last_name": "DOE", "passenger_type": "adult",
"date_of_birth": "1990-01-15", "gender": "male", "nationality": "KGZ",
"document_type": "passport", "document_number": "AN1234567",
"seat_number": "12A"
}
],
"contact_email": "user@example.com", "contact_phone": "+996555000000"
}Возможные ответы:
| Код | Сценарий |
|---|---|
| 201 | Бронь создана; total_amount уже включает seat_fee из зоны места |
| 409 | Место 12A уже занято другой бронью / зарезервировано |
| 422 | Тариф не позволяет выбор места (fare_rule.seat_selection=false) |
| 422 | Неверный формат (^\d{1,2}[A-Z]$) или место за пределами cabin |
В GET /{booking_id} место отображается в passengers[i].seat_number
и в массиве seats[] вместе с seat_fee.
Условия тарифа (обмен / возврат)
Перед оплатой пассажиру нужно показать, разрешены ли обмен/возврат,
сколько они стоят и за какое время до вылета. Эта информация
возвращается в QuoteResponse.fare_rules
per leg (outbound + return при round-trip).
curl -X POST -H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"origin":"BSZ","destination":"OSS","departure_date":"2026-05-22",
"pax":[{"type":"adult","count":1}]}' \
"https://api.nurcore.kg/api/v1/fares/quote"В ответе:
{
"net": "3900.00",
"fare_rules": [
{
"leg": "outbound",
"refundable": true, "changeable": true,
"cancellation_fee": "500.00", "change_fee": "500.00",
"currency": "KGS",
"exchange_threshold_hours": 24.0,
"exchange_penalty_multiplier": 2.0,
"notes": [
"Обмен разрешён: штраф 500.00 KGS (до вылета > 24ч). Если < 24ч до вылета — штраф 1000.00 KGS.",
"Возврат разрешён: штраф 500.00 KGS. Сумма возврата = total − штраф. Возможен до момента вылета."
]
}
]
}Готовые русские строки в notes[] уже учитывают глобальные пороги
(fare.exchange_threshold_hours + fare.exchange_penalty_mult) —
UI может выводить их «как есть».
После создания брони условия тарифа доступны через
GET /api/v1/fares/rules/{fare_rule_id} (id находится в
booking.segments[i].fare_rule_id), либо повторным quote с
тем же route_id + fare_rule_id.
Получение
GET /{id}
Полная карточка брони (passengers, segments, payments, seats).
Authentication: X-API-Key. Каждый клиент видит только свои брони
(B2B-агентство — свои бронирования; consumer_app — созданные через
свой ключ).
GET /{id}/public?access_token=X
Публичная карточка через magic-link. Без X-API-Key — token bound к
booking_id.
GET /lookup-consumer?pnr=X&email=Y
Find my trip — отправляет magic-link на email если PNR + email совпадают. Rate limit: 3/мин, 10/час на IP.
curl "https://api.nurcore.kg/api/v1/bookings/lookup-consumer?pnr=ABC123&email=user@example.com"Подтверждение / отмена
POST /{id}/confirm
Перевод PENDING → CONFIRMED (после оплаты).
POST /{id}/cancel
Отмена B2B / staff. Авто-refund для оплаченных в течение fare-policy (зависит от тарифа).
POST /{id}/cancel-consumer
Self-cancel пассажиром. Два варианта авторизации:
- Magic-link —
?access_token=X(из письма Find My Trip); - Passenger-JWT —
Authorization: Bearer <passenger_access_token>(изPOST /auth/passenger/login). Бронь должна принадлежать владельцу токена (поuser_idилиcontact_email).
# Вариант A: magic-link
curl -X POST "https://api.nurcore.kg/api/v1/bookings/$BOOKING_ID/cancel-consumer?access_token=$ACCESS_TOKEN"
# Вариант B: passenger-JWT
curl -X POST \
-H "Authorization: Bearer $PASSENGER_JWT" \
"https://api.nurcore.kg/api/v1/bookings/$BOOKING_ID/cancel-consumer"Поведение:
- PENDING → place становится свободным (нет рефанда — деньги ещё не списаны).
- PAID → бронь переходит в CANCELLED, refund инициируется по тарифной политике в течение 5–7 рабочих дней.
Response 200:
{
"booking_id": "uuid",
"booking_reference": "ABC123",
"status": "cancelled",
"payment_status": "refund_pending",
"refund_initiated": true,
"refund_message": "Refund will be processed within 5–7 business days"
}POST /{id}/refund-consumer
Запрос возврата по оплаченной броне. Двухвариантная авторизация —
такая же как у cancel-consumer (magic-link ?access_token=X либо
passenger-JWT).
Проверяет fare_rule.refundable из связанного тарифа:
| Семейство | Поведение |
|---|---|
flex / business | Full refund (refund_amount = total_amount) |
classic | Refund − cancellation_fee |
light | 400 Non-refundable (только cancellation без рефанда) |
curl -X POST \
-H "Authorization: Bearer $PASSENGER_JWT" \
"https://api.nurcore.kg/api/v1/bookings/$BOOKING_ID/refund-consumer"Response 200:
{
"booking_id": "uuid",
"booking_reference": "ABC123",
"status": "cancelled",
"payment_status": "refunded",
"refund_amount": 4500.00,
"currency": "KGS",
"fare_family": "classic",
"refundable": true
}Errors:
| Код | Сценарий |
|---|---|
| 400 | Бронь не оплачена / уже отменена / тариф non-refundable |
| 401 / 403 | Невалидный токен / бронь принадлежит другому пассажиру |
| 404 | Бронь не найдена |
Обмен (exchange) — только B2B
GET /{id}/exchange/dates — календарь альтернативных дат
GET /{id}/exchange/flights?date=... — рейсы на дату
POST /{id}/exchange/preview — расчёт + price lock 15 мин
POST /{id}/exchange/execute — выполнить обменДоступно только для agency ключей. Consumer-flow обмена пока не
поддерживается — пассажир обращается к агенту или авиакомпании.
E-ticket
GET /{id}/eticket
PDF посадочного билета. Поддерживает оба способа:
X-API-Key(B2B / Mobile с правами на эту броню)access_token=X(consumer magic-link)
Magic-link
POST /{id}/send-access-link
Перевыпустить magic-link на contact_email. Используется когда
пассажир потерял оригинальное письмо.
Платежи
Есть три способа оплаты — выбирайте под client_type.
| Способ | Кому | Endpoint |
|---|---|---|
| Свой PSP (Kaspi/MBank/...) | consumer-app | POST /{id}/initiate-payment + наш external webhook |
| Wallet B2B-партнёра | agency | POST /{id}/pay-with-balance |
| Pre-integrated PSP (Stripe / Freedom Pay) | consumer-app | POST /{id}/initiate-payment (без external webhook) |
POST /{id}/initiate-payment
Старт оплаты через интегрированный платёжный провайдер. Возвращает
payment_url (для Freedom Pay / других redirect-PSP) либо
client_secret (для Stripe.js). Авторизация — consumer-app key
(X-API-Key).
Body:
{
"amount": 5000.00,
"currency": "KGS",
"return_url": "https://your-site.com/payment-success",
"cancel_url": "https://your-site.com/payment-cancelled"
}| Поле | Тип | Описание |
|---|---|---|
amount | float | Сумма к оплате; если 0 — берётся booking.total_amount |
currency | string | Валюта (3-letter ISO). По умолчанию — валюта брони |
return_url | string | URL редиректа после успешной оплаты |
cancel_url | string | URL редиректа при отмене |
Response 200:
{
"payment_id": "uuid",
"payment_url": "https://provider.com/checkout/...",
"redirect_url": "https://provider.com/checkout/...",
"expires_at": "2026-05-12T12:30:00Z",
"provider": "freedompay",
"client_secret": null,
"stripe_publishable_key": null
}Для Stripe-flow вместо payment_url приходит client_secret —
используйте его на frontend через Stripe.js (stripe.confirmCardPayment(client_secret)).
После успешной оплаты Stripe сам шлёт webhook в payment-service —
никаких дополнительных вызовов от вас не требуется.
Idempotency: поддерживается через заголовок Idempotency-Key
(TTL 24h). Повторный POST с тем же ключом вернёт исходный
payment_id без двойной инициации.
Errors:
| Код | Сценарий |
|---|---|
| 400 | Бронь уже оплачена / бронь в статусе CANCELLED / EXPIRED |
| 404 | Бронь не найдена |
| 409 | Параллельная инициация платежа (повторите через 1–2с) |
POST /{id}/pay-with-balance — только B2B
Списание с partner wallet — для турагентств. Авторизация — agency
ключ (X-API-Key) с привязанным partner_id и достаточным балансом
в валюте брони.
Body: пустой (всё необходимое — total_amount и currency —
берётся из самой брони).
curl -X POST \
-H "X-API-Key: $AGENCY_KEY" \
"https://api.nurcore.kg/api/v1/bookings/$BOOKING_ID/pay-with-balance"Response 200: полная карточка брони (BookingResponse) с
payment_status="paid", status="confirmed".
Поведение:
- Atomic списание с partner wallet (FOR UPDATE на строке кошелька);
- Создаётся запись в
wallet.transactions(type=booking_payment); - Начисляется комиссия партнёра по applicable commission rule
(видно в
booking.commission_amount); - При недостатке баланса в нужной валюте → 422 «Insufficient balance for currency XYZ» (для multi-currency wallet нужно top-up через авиакомпанию).
Drafts (черновики броней)
Черновик — это холд мест без указания пассажиров. Используется для многошаговых UX-сценариев (пользователь выбрал рейс, ищет промокод, советуется с друзьями) — места зарезервированы, но бронь не создаётся до подтверждения.
POST /drafts/
Создать черновик.
curl -X POST \
-H "X-API-Key: $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"flight_id": "uuid",
"fare_plan_id": "uuid",
"fare_price_id": "uuid",
"seat_count": 2,
"contact_email": "user@example.com",
"expires_hours": 24
}' \
"https://api.nurcore.kg/api/v1/bookings/drafts/"Body:
| Поле | Тип | Required | Описание |
|---|---|---|---|
flight_id | UUID | ✓ | ID рейса |
return_flight_id | UUID | — | Для round-trip |
fare_plan_id | UUID | ✓ | Тарифный план |
fare_price_id | UUID | ✓ | Конкретная цена |
seat_count | int (1-9) | — | Количество мест (default 1) |
contact_email | string | — | Email для связи |
contact_phone | string | — | Телефон |
expires_hours | int (1-8760) | — | TTL в часах (default null = бессрочно) |
Response 201:
{
"id": "uuid",
"flight_id": "uuid",
"fare_plan_id": "uuid",
"fare_price_id": "uuid",
"seat_count": 2,
"user_id": "uuid",
"contact_email": "user@example.com",
"status": "active",
"expires_at": "2026-05-13T10:00:00Z",
"created_at": "2026-05-12T10:00:00Z"
}GET /drafts/
Список черновиков текущего пользователя.
Query:
flight_id— фильтр по рейсуstatus—active(default) /released/expiredpage,page_size— пагинация
GET /drafts/{draft_id}
Получить один черновик. Доступ — только владельцу (user_id совпадает).
DELETE /drafts/{draft_id}
Освободить черновик (снять холд). Статус → released. Hard delete не
используется — для аудита.
Когда использовать drafts vs PENDING booking?
| Сценарий | Решение |
|---|---|
| Пользователь не уверен в датах / попутчиках | Draft (без пассажиров, легко изменить) |
| Готовы данные пассажиров, осталась оплата | PENDING booking (15 минут на оплату) |
| Долгий процесс согласования с командой / клиентом | Draft с expires_hours=24 |
Важно: drafts не блокируют availability так же жёстко как PENDING. В overbooked сценариях preference имеет PENDING. См. Inventory M4.
Resume PENDING booking (Sprint 2.6)
POST /{id}/resume-pending?access_token=...
Продлить 15-минутное окно PENDING-брони. Use case: пользователь начал бронирование, закрыл вкладку перед оплатой — через email открыл magic-link → видит "осталось 30 секунд" → нажимает «Возобновить».
Условия:
- Только для
status=PENDING(CONFIRMED/CANCELLED/EXPIRED → 400) - Требует валидный
access_token(как/public) - Не более 3 продлений на бронь (anti-abuse → 429)
Query:
| Param | Required |
|---|---|
access_token | ✓ — bound к этому booking_id |
Response 200:
{
"booking_reference": "ABC123",
"status": "pending",
"expiry_date": "2026-05-12T11:15:00Z",
"extended_by_minutes": 15,
"payment_url": "/booking/pay?bid=uuid&access_token=..."
}Errors:
| Code | Сценарий |
|---|---|
| 400 | Не PENDING статус |
| 401 / 403 | Invalid / mismatched access_token |
| 429 | Превышен лимит продлений (3) |
| 404 | Бронь не найдена |