Бронь и оплата

Бронь и оплата по шагам

Главный сценарий от начала до конца: родитель выбрал смену → ваша CRM оформляет бронь → лагерь отправляет родителю счёт Kaspi → родитель платит → место закреплено → остаток оплачивается к сроку.

Все данные в примерах вымышленные. Ключ — в переменной ZB_KEY (см. Первый запрос).

Что нужно знать до начала

Пять правил, на которых держится весь сценарий

  1. Бронь сразу занимает место в квоте вашего отдела, но держит его только 24 часа. За сутки плательщик должен внести минимальный платёж. Не внёс — бронь отменяется сама.
  2. Деньги принимает только лагерь — счётом Kaspi. Счёт уходит с Kaspi лагеря на телефон плательщика. Ваш отдел денег не принимает и отметить бронь оплаченной не может.
  3. Дата рождения ребёнка обязательна. Без неё бронь не оформится.
  4. У брони одновременно только один неоплаченный счёт.
  5. Каждый POST — с заголовком Idempotency-Key, кроме расчёта стоимости и проверки «можно ли оформить». Это защищает от двойной брони при обрыве связи (см. Повтор запроса).

Что пригодится для оформления:

ЧтоОткуда взять
Номер смены (shift_id)GET /shifts?on_sale=true
ФИО ребёнкаОт родителя. Полностью: фамилия, имя, отчество (если есть)
Дата рождения ребёнкаОт родителя, формат ГГГГ-ММ-ДД
Город ребёнка (city_id)Номер из справочника городов лагеря. Список номеров городов лагерь передаёт вместе с ключом; в ответах API город приходит как {"id": "1", "name": "Астана"}
ФИО и телефон родителяОт родителя. Телефон — любой записью: 8 701 000 00 01, +7 (701) 000-00-01
Телефон плательщикаТолько если платит не родитель — на него уйдёт счёт Kaspi
Точка посадкиЕсли нужен трансфер: transfer_point_id из transfer_prices смены

Как проходит бронь — схема

Code
оформление ┌─────────── 24 часа ───────────┐ POST /bookings ──► awaiting (место придержано) │ │ счёт Kaspi на минимум ушёл │ │ │ плательщик оплатил минимум не оплатил до hold_until │ │ ▼ ▼ secured (место закреплено) unpaid: бронь отменена, │ место вернулось в квоту день full_payment_due: счёт на остаток │ ▼ остаток оплачен: debt = 0.00
  1. Найдите смену и проверьте места

    TerminalCode
    curl "https://partners.zvezdnyy-burabai.edu.kz/ext/v1/shifts?on_sale=true" \ -H "Authorization: Bearer $ZB_KEY"

    Подходит смена с on_sale: true и seats.free больше нуля. Пример ответа и что значит каждое поле — в Первом запросе. Одну смену можно перечитать по номеру: GET /shifts/12.

    Места меняются каждую минуту — их занимают и другие менеджеры вашего отдела. Не храните seats.free у себя надолго: перечитывайте смену перед оформлением или следите за событием shift.seats_changed.

  2. Посчитайте стоимость (по желанию)

    POST /quote — сколько будет стоить бронь. Считает тем же расчётом, что и оформление, поэтому цифры совпадут. Ничего не создаёт, место не занимает, Idempotency-Key не нужен. Нужно право read.

    TerminalCode
    curl https://partners.zvezdnyy-burabai.edu.kz/ext/v1/quote \ -H "Authorization: Bearer $ZB_KEY" \ -H "Content-Type: application/json" \ -d '{ "shift_id": "12", "needs_transfer": true, "transfer_point_id": "4", "discount": { "amount": "10000", "note": "Второй ребёнок в семье" } }'

    Ответ 200:

    JSONCode
    { "price": { "amount": "250000.00", "currency": "KZT" }, "transfer_fee": { "amount": "15000.00", "currency": "KZT" }, "discount": { "amount": "10000.00", "currency": "KZT" }, "total": { "amount": "255000.00", "currency": "KZT" }, "min_payment_now": { "amount": "50000.00", "currency": "KZT" }, "hold_hours": 24, "full_payment_due": "2027-06-24" }
    • total — к оплате всего: 250 000 + 15 000 − 10 000 = 255 000 ₸;
    • min_payment_now — минимальный платёж: на эту сумму уйдёт счёт при оформлении. 0.00 — предоплата не нужна, бронь сразу закреплена;
    • hold_hours — сколько часов держится место без минимального платежа. null — предоплата не нужна.

    Скидка больше max_discount смены — отказ discount_over_max (большую скидку даёт только администратор лагеря).

  3. Проверьте, пройдёт ли оформление (по желанию)

    POST /bookings/check — те же проверки, что при оформлении, но без брони: продажи открыты, у отдела есть место, ребёнок ещё не записан на эту смену, нет запрета. Удобно, чтобы показать менеджеру все препятствия сразу, ещё до ввода остальных данных. Нужно право bookings; Idempotency-Key не нужен; не больше 300 проверок в час на отдел.

    TerminalCode
    curl https://partners.zvezdnyy-burabai.edu.kz/ext/v1/bookings/check \ -H "Authorization: Bearer $ZB_KEY" \ -H "Content-Type: application/json" \ -d '{ "shift_id": "12", "child": { "full_name": "Тестов Тимур Аскарович", "birth_date": "2016-04-12" }, "parent": { "phone": "8 701 000 00 01" } }'

    Ответ 200, если оформить можно:

    JSONCode
    { "can_book": true, "reasons": [] }

    Если нельзя — все причины сразу:

    JSONCode
    { "can_book": false, "reasons": [ { "code": "duplicate_child", "message": "Этот ребёнок уже записан на эту смену. Если это ошибка — обратитесь в лагерь" } ] }

    Возможные code: sales_closed, shift_not_open, quota_exhausted, no_quota, duplicate_child, blacklisted, birth_date_implausible (что каждый значит — в Ошибках). can_book: true не гарантирует места: между проверкой и оформлением его может занять другой менеджер.

  4. Оформите бронь

    POST /bookings с заголовком Idempotency-Key. Нужно право bookings.

    curl https://partners.zvezdnyy-burabai.edu.kz/ext/v1/bookings \ -H "Authorization: Bearer $ZB_KEY" \ -H "Idempotency-Key: 6f1c2a9e-3b7d-4e0a-9c51-2d8e4f7a1b03" \ -H "Content-Type: application/json" \ -d '{ "shift_id": "12", "child": { "full_name": "Тестов Тимур Аскарович", "birth_date": "2016-04-12", "city_id": "1" }, "parent": { "full_name": "Тестова Айгерим Болатовна", "phone": "8 701 000 00 01" }, "needs_transfer": true, "transfer_point_id": "4", "discount": { "amount": "10000", "note": "Второй ребёнок в семье" }, "invoice": "min", "external_id": "deal-10452", "metadata": { "crm_deal": "10452", "source": "instagram" }, "partner_manager": "Пример Менеджер" }'

    Поля запроса:

    ПолеОбязательноЧто это
    shift_idдаНомер смены
    child.full_nameдаФИО ребёнка
    child.birth_dateдаДата рождения ГГГГ-ММ-ДД. Без неё — отказ birth_date_required (почему — ниже)
    child.city_idдаГород из справочника городов лагеря
    parent.full_nameдаФИО родителя
    parent.phoneдаТелефон родителя, любой записью
    payer_phoneнетТелефон плательщика, если платит не родитель: счета Kaspi уйдут на него
    needs_transferнетtrue — нужен трансфер. По умолчанию false
    transfer_point_idесли трансферТочка посадки из transfer_prices смены
    discountнетСкидка: amount — тенге строкой ("10000"), note — за что. Не больше max_discount смены
    invoiceнетКакой счёт Kaspi отправить сразу: min, full или none (таблица ниже)
    commentнетКомментарий для лагеря
    together_withнетНомера ваших броней той же смены, с кем поселить вместе (до 50)
    external_idнетНомер сделки или заявки в вашей CRM (до 255 знаков, уникален в вашем отделе)
    metadataнетВаши метки: до 20 пар «ключ — значение»
    partner_managerнетКто продал в вашей CRM (до 200 знаков) — для ваших отчётов

    Поле, которого нет в этой таблице, — отказ 422 invalid_input (имя поля — в message). Так опечатка в имени поля не превращается в молча потерянные данные.

    Какой счёт отправить сразу (invoice):

    ЗначениеЧто произойдёт
    minСчёт на минимальный платёж смены. Так и будет, если поле не передать, когда у смены минимальный платёж больше нуля
    fullСчёт сразу на всю сумму к оплате
    noneСчёт не отправляется. Место всё равно держится только 24 часа — выставьте счёт сами до hold_until

    Если у смены минимальный платёж 0.00 и invoice не передан, счёт не отправляется: бронь сразу закреплена, оплатить её можно позже (счётом full или автоматическим счётом на остаток).

    Почему дата рождения обязательна

    В базе лагеря ребёнок узнаётся по ФИО и дате рождения вместе. По ним лагерь понимает, что «Тестов Тимур, 12.04.2016» — тот же ребёнок, что ездил прошлым летом, а не новый; находит дубль, если ребёнка уже записали на эту смену; назначает отряд по возрасту.

    Без даты рождения двух детей с одним именем в одной семье не различить, а ребёнок другого отдела мог бы «перейти» к вам по одному совпадению имени и телефона. Поэтому без даты API не оформляет бронь вовсе (400 birth_date_required). Дата должна быть правдоподобной: в лагерь едут дети от 3 до 19 лет на дату заезда, иначе — birth_date_implausible. Дата в будущем — bad_birth_date.

    Ответ 201 — бронь целиком:

    JSONCode
    { "id": "15230", "version": "2027-05-14T09:31:07.482913Z", "status": "active", "payment_state": "awaiting", "hold_until": "2027-05-15T09:31:07.482913Z", "shift": { "id": "12", "number": 3, "date_start": "2027-07-01", "date_end": "2027-07-14" }, "child": { "id": "40871", "full_name": "Тестов Тимур Аскарович", "birth_date": "2016-04-12", "city": { "id": "1", "name": "Астана" } }, "parent": { "id": "22014", "full_name": "Тестова Айгерим Болатовна", "phone": "+77010000001" }, "payer_phone": null, "transfer": { "needed": true, "fee": { "amount": "15000.00", "currency": "KZT" }, "point": { "id": "4", "name": "Астана, ж/д вокзал" } }, "price": { "amount": "250000.00", "currency": "KZT" }, "discount": { "amount": { "amount": "10000.00", "currency": "KZT" }, "note": "Второй ребёнок в семье" }, "total": { "amount": "255000.00", "currency": "KZT" }, "paid": { "amount": "0.00", "currency": "KZT" }, "debt": { "amount": "255000.00", "currency": "KZT" }, "full_payment_due": "2027-06-24", "squad_number": null, "checkin": { "checked_in_at": null, "checked_out_at": null }, "voucher": { "status": "none" }, "voucher_number": "03-154", "comment": "", "cancelled_reason": null, "contacts_hidden": false, "external_id": "deal-10452", "metadata": { "crm_deal": "10452", "source": "instagram" }, "partner_manager": "Пример Менеджер", "created_at": "2027-05-14T09:31:07.482913Z", "updated_at": "2027-05-14T09:31:07.482913Z" }

    Сохраните у себя id брони — по нему все дальнейшие запросы. Телефон в ответе всегда в виде +7XXXXXXXXXX, даже если вы прислали 8 701 ….

    Самые частые отказы при оформлении:

    ОтветЧто значит и что делать
    400 birth_date_requiredНет даты рождения. Спросите у родителя
    409 quota_exhaustedМеста вашего отдела на смене закончились. Предложите другую смену или попросите лагерь добавить мест
    409 no_quotaВашему отделу не выделены места на эту смену
    409 sales_closedПродажи на смену закрыты
    409 duplicate_childРебёнок уже записан на эту смену (возможно, другим отделом). Если это ошибка — в лагерь
    403 blacklistedОформить этого ребёнка нельзя. Причину API не сообщает — обратитесь в лагерь
    400 discount_over_maxСкидка больше разрешённой
    409 external_id_takenЭтот external_id уже у другой вашей брони (в message — её номер). Скорее всего, бронь уже оформлена — прочитайте её: GET /bookings/by-external-id/deal-10452

    Все коды — в Ошибках.

  5. Лагерь отправляет счёт Kaspi

    При invoice: min или full счёт создаётся вместе с бронью и уходит плательщику очередью — обычно за несколько секунд. Вы узнаете об этом событиями:

    • invoice.queued — счёт создан и ждёт отправки в Kaspi;
    • invoice.issued — счёт у плательщика в приложении Kaspi.

    Счета брони — GET /bookings/15230/invoices:

    JSONCode
    { "items": [ { "id": "731", "booking_id": "15230", "amount": { "amount": "50000.00", "currency": "KZT" }, "phone": "+77010000001", "state": "issued", "cancelling": false, "payment_type": "prepayment", "purpose": "Путёвка 03-154, смена 3, 01.07–14.07, Тестов Тимур Аскарович", "created_at": "2027-05-14T09:31:07.482913Z", "issued_at": "2027-05-14T09:31:12.104377Z", "paid_at": null, "payment_id": null, "error": null, "version": "2027-05-14T09:31:12.104377Z" } ], "next_cursor": null }
    • purpose — текст, который плательщик видит в Kaspi рядом с суммой. Подскажите родителю искать счёт с этим текстом;
    • payment_type: prepayment — счёт меньше полной суммы, payment — на весь остаток.

    Если счёт не выставился (invoice.error, у счёта заполнено error) — чаще всего телефон плательщика не зарегистрирован в Kaspi. Что делать — в Счета Kaspi и оплата.

  6. Плательщик оплачивает

    Плательщик открывает Kaspi, видит счёт и оплачивает. Через несколько секунд лагерь узнаёт об оплате, и приходят события (обычно в таком порядке, но полагайтесь на номера событий, а не на порядок типов):

    • invoice.paid — счёт оплачен;
    • payment.received — платёж записан;
    • booking.secured — минимальный платёж набран, место закреплено.

    Платёж — GET /bookings/15230/payments или по номеру из события:

    JSONCode
    { "id": "9120", "booking_id": "15230", "amount": { "amount": "50000.00", "currency": "KZT" }, "type": "prepayment", "source": "kaspi_invoice", "method": "kaspi_invoice", "invoice_id": "731", "paid_on": "2027-05-14", "verified": false, "statement_line": null, "bank": "Kaspi", "legal_entity": { "id": "1", "name": "ТОО «Пример»", "legal_name": "Товарищество с ограниченной ответственностью «Пример»", "bin": "000000000000", "iik": "KZ000000000000000000", "onec_organization": null }, "created_at": "2027-05-14T10:02:45.918201Z", "version": "2027-05-14T10:02:45.918201Z" }

    Бронь после этого (GET /bookings/15230, только изменившиеся поля):

    JSONCode
    { "id": "15230", "payment_state": "secured", "paid": { "amount": "50000.00", "currency": "KZT" }, "debt": { "amount": "205000.00", "currency": "KZT" } }

    verified: false — бухгалтерия лагеря ещё не сверила платёж с банком. Это не мешает: остаток уменьшился и место закреплено сразу. Когда сверят — придёт событие payment.verified.

  7. Если плательщик не оплатил за сутки

    Не пришёл минимальный платёж до hold_until — лагерь сам отменяет бронь (обычно в течение нескольких минут после срока):

    • событие booking.hold_expired;
    • у брони status: cancelled, payment_state: unpaid;
    • место вернулось в квоту (shift.seats_changed);
    • неоплаченный счёт гасится в Kaspi.

    Ребёнок при этом остаётся в вашей базе — чтобы менеджер мог перезвонить. Список таких броней на обзвон — GET /reports/unpaid. Если семья всё-таки хочет ехать — восстановите бронь, если места ещё есть.

  8. Остаток — к сроку полной оплаты

    В день full_payment_due лагерь сам выставляет плательщику один счёт на весь остаток. Ничего делать не нужно. Родитель хочет заплатить раньше или частями — выставьте счёт сами (см. Счета Kaspi и оплата).

    Когда остаток оплачен — debt становится 0.00. Бронь оплачена полностью.

Как узнавать о переменах

Не опрашивайте каждую бронь по кругу — для этого есть события: лента изменений вашего отдела. Самый удобный способ — вебхуки: лагерь сам присылает вам каждое событие. Если своего публичного сервера нет — опрос ленты раз в 10–30 секунд.

Какие события важны в этом сценарии:

СобытиеЧто сделать у себя
booking.createdБронь появилась (в том числе оформленная менеджером вашего отдела в CRM лагеря)
invoice.issuedСказать менеджеру: «счёт у родителя в Kaspi»
invoice.errorПредупредить менеджера: счёт не ушёл, проверить телефон
payment.receivedОбновить «оплачено» и «остаток» — перечитайте бронь
booking.securedМесто закреплено — сделка выиграна
booking.hold_expiredБронь снята по неоплате — задача менеджеру на звонок
booking.cancelledБронь отменена (вами или лагерем)

Несколько детей одной семьи

POST /bookings/batch оформляет от 1 до 10 детей одной семьи за один раз: или все, или никого. Если не прошёл хотя бы один ребёнок (нет мест, дубль, запрет), не оформляется никто, а в message сказано, какой ребёнок и почему: «Ребёнок 2: …».

TerminalCode
curl https://partners.zvezdnyy-burabai.edu.kz/ext/v1/bookings/batch \ -H "Authorization: Bearer $ZB_KEY" \ -H "Idempotency-Key: 0b8e5d14-7c3a-4f29-a6e1-93d2c5b80f47" \ -H "Content-Type: application/json" \ -d '{ "parent": { "full_name": "Тестова Айгерим Болатовна", "phone": "8 701 000 00 01" }, "invoice": "min", "together": true, "children": [ { "shift_id": "12", "child": { "full_name": "Тестов Тимур Аскарович", "birth_date": "2016-04-12", "city_id": "1" }, "external_id": "deal-10452-1" }, { "shift_id": "12", "child": { "full_name": "Тестова Алия Аскаровна", "birth_date": "2018-09-30", "city_id": "1" }, "discount": { "amount": "10000", "note": "Второй ребёнок в семье" }, "external_id": "deal-10452-2" } ] }'

Ответ 201 — {"items": [бронь, бронь]} в порядке детей запроса. У каждого ребёнка — своя бронь и свой счёт Kaspi. together: true — поселить вместе детей семьи, попавших на одну смену. У каждого ребёнка можно указать свой invoice; если не указан — берётся общий.

Дальше

Last modified on