Quote API (Pricing Pipeline)
Единый источник цены для бронирования, обмена и возврата с Decimal-точностью.
Quote API — единая точка расчёта цены для всех потоков (booking creation, exchange, refund). Решает 3 проблемы legacy-расчёта:
- Floating-point ошибки — все суммы в Decimal с округлением HALF_UP (ADR 0001)
- Currency drift — penalty в exchange всегда в
original_currency(Bug #4) - Non-determinism — quote замораживается в Redis на 300s, гарантированная цена
Base URL: https://api.nurcore.kg/api/v1/fares
Endpoints
| Endpoint | Назначение | Auth |
|---|---|---|
POST /quote | Создать quote для booking creation | Optional* |
GET /quote/{quote_id} | Получить snapshot quote (Redis) | Optional |
POST /quote/exchange | Quote для обмена билета (M6.A) | Optional* |
POST /quote/refund | Quote для возврата (M6.C) | None |
GET /audit-log | История всех изменений цены | Staff JWT |
* Auth опционален, но partner_id в request игнорируется без auth
(ADR 0003 — anti-spoofing).
POST /quote
Базовый расчёт цены для одного направления / даты / pax mix.
curl -X POST \
-H "X-API-Key: $PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{
"route_id": "uuid",
"departure_date": "2026-05-15",
"pax": [
{"type": "adult", "count": 2},
{"type": "child", "count": 1}
],
"fare_family_id": "uuid-classic",
"channel": "web",
"ancillaries": [
{"code": "BG23", "qty": 2}
],
"promo_code": "SUMMER10",
"target_currency": "KGS"
}' \
"https://api.nurcore.kg/api/v1/fares/quote"Request body
| Поле | Тип | Required | Описание |
|---|---|---|---|
route_id | UUID | one_of* | ID маршрута |
origin + destination | string(3) + string(3) | one_of* | IATA коды (альтернатива route_id) |
departure_date | ISO date | ✓ | Дата вылета |
return_date | ISO date | — | Для round-trip |
pax | array(1-9) | ✓ | Список pax по типам |
fare_class | string | — | RBD (Y/B/M/H/...) для preferred bucket |
fare_family_id | UUID | — | Семейство тарифа (LIGHT/CLASSIC/FLEX) |
cabin | enum | — | economy / business / first |
channel | enum | — | web / mobile / partner / kiosk (default: web) |
partner_id | UUID | — | (только с auth) применит partner-discount |
flight_id | UUID | — | Quote для конкретного рейса (вместо schedule-based) |
promo_code | string | — | Промо-код |
ancillaries | array | — | SSR с qty ([{code: "BG23", qty: 2}]) |
target_currency | string(3) | — | Конвертация в указанную валюту |
*one_of(route_id, [origin+destination]) — должно быть указано одно.
Response 200
{
"quote_id": "abc123XyZ...",
"valid_until": "2026-05-12T10:30:00Z",
"currency": "KGS",
"channel": "web",
"partner_id": null,
"gross": "26500.00",
"channel_discount": "0.00",
"commission_amount": "0.00",
"net": "26500.00",
"round_trip": false,
"segment_prices": null,
"line_items": [
{
"type": "base_fare",
"description": "Adult × 2",
"amount": "7600.00",
"currency": "KGS"
},
{
"type": "base_fare",
"description": "Child × 1",
"amount": "2660.00",
"currency": "KGS"
},
{
"type": "promo",
"description": "SUMMER10 — 10% off",
"amount": "-1000.00",
"currency": "KGS",
"rate": "10%"
},
{
"type": "ancillary",
"description": "Багаж 23 кг x2",
"amount": "3000.00",
"currency": "KGS"
}
],
"applied_rules": [
{
"rule_type": "load_factor",
"name": "Load factor 78% → +5%",
"factor": "1.05"
},
{
"rule_type": "promo",
"name": "SUMMER10",
"delta": "-1000.00"
}
],
"fare_class": "M",
"fare_family_id": "uuid",
"flight_id": "uuid",
"pax_count": 3
}Семантика полей
gross— цена до channel discount (публичная цена)channel_discount— отрицательное число (0 для web/mobile, отрицательное для partner)commission_amount—abs(channel_discount)для отчётностиnet— что фактически платит клиентvalid_until— TTL Redis, после этой датыquote_idinvalidround_trip—trueеслиnetуже включает оба плеча (см. ниже)segment_prices—["<outbound>", "<return>"]per-leg base subtotal (Decimal-as-string), только при round-trip; иначеnull
Per-pax-type pricing
Каждый pax.type тарифицируется по своему FareRule — adult и
child имеют разные цены (включая discount_percentage на FarePrice).
Отдельный line_item на каждый тип/плечо: "Adult × 2", "Child × 1".
infant= 0 (политика: lap infant без места; отдельного infant FareRule в системе нет). Всегда"Infant × N (free, lap infant)" → 0.00.- Если для типа нет своего FareRule — fallback на adult-тариф.
- Fixed per-pax сборы (сервисный сбор) считаются за платный pax за плечо — infant исключён, round-trip → ×2.
Правила тарифа: fare_rules (refund/exchange policy)
В ответе QuoteResponse.fare_rules — список per leg
(outbound + return при round-trip). Каждая запись описывает,
можно ли вернуть и обменять билет, сколько это будет стоить
и за какое время до вылета меняется штраф.
{
"fare_rules": [
{
"leg": "outbound",
"fare_rule_id": "…",
"fare_family_id": "…",
"refundable": true,
"changeable": true,
"cancellation_fee": "500.00",
"change_fee": "500.00",
"currency": "KGS",
"advance_purchase_days": null,
"exchange_threshold_hours": 24.0,
"exchange_penalty_multiplier": 2.0,
"notes": [
"Обмен разрешён: штраф 500.00 KGS (до вылета > 24ч). Если < 24ч до вылета — штраф 1000.00 KGS.",
"Возврат разрешён: штраф 500.00 KGS. Сумма возврата = total − штраф. Возможен до момента вылета."
]
},
{
"leg": "return",
"refundable": false,
"changeable": false,
"notes": [
"Обмен не разрешён правилами тарифа.",
"Возврат не разрешён правилами тарифа."
]
}
]
}Семантика полей:
| Поле | Значение |
|---|---|
refundable | Тариф разрешает возврат (если false — /quote/refund вернёт refund_amount=0) |
changeable | Тариф разрешает обмен (если false — /quote/exchange → 422) |
cancellation_fee | Штраф за возврат per pax, фиксированный из FareRule |
change_fee | Штраф за обмен per pax, до threshold |
exchange_threshold_hours | Порог (часы до вылета), глобальная настройка fare.exchange_threshold_hours |
exchange_penalty_multiplier | Коэффициент при < threshold (default 2.0) — change_fee × 2 |
advance_purchase_days | Если задано — тариф недоступен ближе к вылету |
notes | Готовые русские строки для UI — не нужно генерировать самим |
Правило обмена:
если время_до_вылета >= exchange_threshold_hours: штраф = change_fee
иначе: штраф = change_fee × multiplierПравило возврата (на момент вылета):
если refundable: refund_amount = total − cancellation_fee (минимум 0)
иначе: refund_amount = 0, penalty = total (full forfeit)Round-trip caveat: у направлений «туда» и «обратно» может быть разная политика (разные FareRule). UI должен показать обе записи из
fare_rules[], не агрегировать.
Round-trip (return_date)
При указании return_date вместе с origin+destination engine
резолвит обратный маршрут (swap O/D), тарифицирует обратное плечо
per-pax-type, и net = полная стоимость поездки (оба плеча).
round_trip=true, segment_prices=["<out>","<ret>"].
⚠️ Round-trip требует
origin+destination. Сroute_idбез origin/destination обратный маршрут не резолвится → quote one-way (round_trip=false); booking-service добьёт return-плечо отдельно.
Пример round-trip line_items (1 adult + 1 child + 1 infant):
Туда: Adult × 1 3800.00
Туда: Child × 1 2660.00
Туда: Infant × 1 0.00 (free, lap infant)
Обратно: Adult × 1 3800.00
Обратно: Child × 1 2660.00
Обратно: Infant × 1 0.00 (free, lap infant)
Сервисный сбор 400.00 (100 × 2 paid pax × 2 legs)
net = 13320.00 ; segment_prices = ["6460.00","6460.00"]Pricing pipeline order
base × pax → dynamic → ancillaries → taxes → channel discount → promo → netСм. ADR 0001 для всех правил.
GET /quote/{quote_id}
Получить snapshot quote по ID (для booking creation flow).
curl -H "X-API-Key: $PUBLISHABLE_KEY" \
"https://api.nurcore.kg/api/v1/fares/quote/abc123XyZ..."Response: идентичен POST /quote (тот же QuoteResponse).
Errors:
| Code | Сценарий |
|---|---|
| 410 | Quote expired (valid_until < now) или не существует — нужен новый POST |
POST /quote/exchange (M6.A)
Расчёт обмена через unified pipeline с Decimal-precision.
curl -X POST \
-H "X-API-Key: $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"original_booking_id": "uuid",
"original_total_amount": "5000.00",
"original_currency": "KGS",
"original_fare_rule_id": "uuid",
"original_scheduled_departure": "2026-05-20T07:30:00Z",
"new_quote": {
"route_id": "uuid",
"departure_date": "2026-05-25",
"pax": [{"type": "adult", "count": 1}],
"fare_family_id": "uuid-classic"
}
}' \
"https://api.nurcore.kg/api/v1/fares/quote/exchange"Request body
new_quote — полностью идентичен QuoteRequest для нового рейса. Все
остальные поля описывают исходную бронь.
Response 200
Extends QuoteResponse для new flight плюс exchange fields:
{
"quote_id": "exchange_abc...",
"valid_until": "2026-05-12T10:35:00Z",
"currency": "KGS",
"gross": "6500.00",
"net": "6500.00",
"original_total": "5000.00",
"fare_difference": "1500.00",
"penalty": "500.00",
"refund_amount": "0.00",
"extra_to_charge": "2000.00",
"changeable": true
}Логика exchange:
| Сценарий | extra_to_charge | refund_amount |
|---|---|---|
new > old, fare_diff + penalty | max(0, diff + penalty) | 0 |
new < old, refundable=true | 0 | |diff| - penalty (max 0) |
new < old, refundable=false | 0 | 0 (no refund) |
changeable=false | — | 422 в response |
Penalty rules (per FareRule):
change_fee— стандартный штраф- doubled если до
original_scheduled_departure< 24 часов
POST /quote/refund (M6.C)
Расчёт возврата (full cancel брони).
curl -X POST \
-H "X-API-Key: $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"original_booking_id": "uuid",
"original_total_amount": "5000.00",
"original_currency": "KGS",
"original_fare_rule_id": "uuid",
"original_scheduled_departure": "2026-05-20T07:30:00Z"
}' \
"https://api.nurcore.kg/api/v1/fares/quote/refund"Response 200
{
"original_total": "5000.00",
"refund_amount": "4500.00",
"penalty": "500.00",
"refundable": true,
"currency": "KGS",
"fare_rule_id": "uuid",
"notes": "10% cancellation fee per Classic rules"
}Логика:
| FareRule.refundable | within-24h | Result |
|---|---|---|
| true | false | refund = total - cancellation_fee |
| true | true | refund = total - cancellation_fee × 2 |
| false | any | refund = 0, penalty = total (full forfeit) |
Tax / Fees shape (v2, IATA-compliant)
С 2026-06-01 line_items[type=tax] содержит дополнительные поля
в metadata для BSP/NDC compatibility:
{
"type": "tax",
"description": "VAT — НДС",
"amount": 912.24,
"currency": "KGS",
"rate": "12%",
"metadata": {
"code": "VAT",
"tax_type": "percentage",
"configured_amount": 12,
"applies_to": "per_itinerary",
"refundable": true,
"jurisdiction": "KG"
}
}| Поле | Значение | Назначение |
|---|---|---|
code | IATA tax code (YQ/YR/XF/AY/UB) или custom (VAT/AIRPORT_KG/BOOK_FEE) | BSP reporting / interlining |
applies_to | per_pax / per_segment / per_itinerary | Multiplier convention |
refundable | true (VAT/YQ) / false (некоторые airport fees, AY US security) | Refund flow knows что возвращать |
jurisdiction | KG/carrier/US/... | Tax reporting per country |
Real-world KG breakdown (BSZ→OSS round-trip, 2 ADT):
| Component | Code | Type | applies_to | Refundable | Calculation |
|---|---|---|---|---|---|
| VAT 12% | VAT | percentage | per_itinerary | ✅ | 7602 × 12% = 912.24 |
| Топливный сбор | YQ | fixed | per_pax | ✅ | 500 × 2 × 2 legs = 2000 |
| Аэропортовый сбор | AIRPORT_KG | fixed | per_segment | ❌ | 200 × 2 legs = 400 |
| Сервисный сбор | BOOK_FEE | fixed | per_itinerary | ❌ | 100 × 1 = 100 |
applies_to semantics:
per_pax— fixed × paid_pax × num_legs (стандарт для security fees)per_segment— fixed × num_legs (airport fee, не зависит от pax)per_itinerary— fixed × 1 (booking fee, single charge)- percentage type → всегда
rate × base(base уже включает all-pax all-legs)
Backward compat: legacy v1 shape
[{name, amount, type}](без applies_to) продолжает работать — calculator определяет mode automatically.
Per-customer promo limit
С 2026-06-01 промо-коды могут иметь max_uses_per_customer (fare.promo_codes column).
Если задан и customer_email передан в quote — проверяется по нормализованному
email (lowercase + trim):
{
"applied_rules": [
{
"rule_type": "promo_rejected",
"name": "Promo SUMMER25 not applied",
"description": "Reason: per_customer_limit_reached"
}
]
}Передавайте customer_email в quote для preview-check:
curl -X POST -H "X-API-Key: $KEY" -d '{
"origin": "BSZ", "destination": "OSS",
"departure_date": "2026-08-16",
"pax": [{"type": "ADT", "count": 1}],
"promo_code": "SUMMER25",
"customer_email": "user@example.com"
}' "https://api.nurcore.kg/api/v1/fares/quote"Реальное списание usage происходит на стороне booking-service после
POST /bookings/{id}/confirm — quote-time check только preview.
PADIS passenger codes (IATA standard)
QuoteRequest.pax[].type принимает PADIS-стандарт codes (helpful для NDC / GDS интеграций):
| PADIS | NurCore internal | Описание |
|---|---|---|
ADT | adult | 12+ |
CHD / CHLD | child | 2-11 |
INF / INFT | infant | 0-2, lap (free) |
INS / INFS | infant_with_seat | 0-2, with paid seat |
YTH / STU | youth | 12-25 age-based discount |
SRC / SR | senior | 65+ age-based discount |
MIL / ITX | adult | Mapped to adult (no separate fare) |
{
"pax": [
{"type": "ADT", "count": 2},
{"type": "INF", "count": 1}
]
}И paxType / passenger_type / quantity aliases работают (B2B compat).
Использование в booking flow
Quote API — рекомендуемый путь для создания брони. Альтернативный
legacy-путь (без quote_id) сохранён для backward-compat, но имеет
floating-point ошибки.
// 1. Получить quote
const quoteResp = await fetch("/api/v1/fares/quote", {
method: "POST",
body: JSON.stringify({route_id, departure_date, pax, fare_family_id})
});
const quote = await quoteResp.json();
// 2. Показать total user'у, дать подтвердить (≤300 секунд!)
showPrice(quote.net, quote.currency);
// 3. Создать бронь с quote_id
const bookingResp = await fetch("/api/v1/bookings/", {
method: "POST",
body: JSON.stringify({
flight_id: quote.flight_id,
fare_price_id: quote.fare_resolution.fare_id,
quote_id: quote.quote_id, // ← цена берётся из quote, не пересчитывается
passengers,
contact_email,
contact_name
})
});Channel discount (B2B)
Если auth + partner_id указан в request — pipeline применит channel
discount из partnerships (например 10%). Партнёр видит net-цену
(уже со скидкой), а commission_amount показывает его выгоду.
{
"gross": "10000.00",
"channel_discount": "-1000.00",
"commission_amount": "1000.00",
"net": "9000.00"
}См. ADR 0002 — Channel Discount.
Dynamic pricing (M3)
Pipeline применяет dynamic multipliers если DYNAMIC_PRICING_ENABLED=true
в deployment env:
- Load factor — заполнение
>70%→ +5%,>85%→ +10% - DOW (day of week) — пн-чт vs выходные
- Season — high/low season multipliers
- Booking curve — DTD (days to departure)
Применённые multipliers видны в applied_rules[] для transparency.
Audit log
Все изменения цены (booking total_amount, applied promo, exchange penalty)
автоматически логируются в fare.price_audit_log через SQLAlchemy event
listeners.
curl -H "Authorization: Bearer $JWT" \
"https://api.nurcore.kg/api/v1/fares/audit-log?\
booking_id=$BOOKING_ID&from=2026-05-01"См. Audit Log endpoint для всех фильтров.
Errors
| Code | Сценарий |
|---|---|
| 400 | No matching fare для route+date / FareRule не найден / Validation |
| 410 | quote_id expired (>300s) |
| 422 | Invalid request body (Pydantic validation) |
| 503 | Inventory service unavailable |