ГлавнаяДокументация › Fallback-комбо

Pattern · Production · KK17 BEAUTY

Fallback-сценарий: invoice push + PDF-чек

Готовый паттерн: основной поток — Kaspi Pay invoice через pay.proverkacheka.kz, страховка — загрузка PDF-чека через api.proverkacheka.kz. Когда invoice push не доходит (отключены уведомления, нет сети, таймаут API, webhook потерялся) — пользователь всё равно может оплатить по обычной ссылке Kaspi и прислать PDF, а бот его автоматически верифицирует.

pay.proverkacheka.kz/api · POST /v1/invoice/create api.proverkacheka.kz · POST /upload
Получить API-ключ → Та же тема на proverkacheka.kz → Назад к референсу
Полный гайд для AI / разработчика (оба сервиса)
Показать содержимое

Зачем нужна комбинация

Invoice push — самый удобный способ оплаты: клиент жмёт одну кнопку в Kaspi.kz и сделка готова. Но он завязан на инфраструктуру push-уведомлений Kaspi и доставку нашего webhook'а к вам. Любое из звеньев иногда падает:

В каждом из этих случаев у Kaspi после оплаты всё равно есть PDF-чек — он приходит клиенту по почте и доступен в истории Kaspi. Поэтому разумный fallback — попросить клиента прислать PDF-файл этого чека и верифицировать его через api.proverkacheka.kz/upload: сервис проверяет QR-код, сверяет ИИН/БИН продавца и сумму, и подтверждает сделку без участия webhook'а.

Результат. Конверсия не теряется ни на одном из 5 кейсов выше. Если основной канал отвалился — бот молча переключается на загрузку PDF, а если оба пути сработали (webhook пришёл и PDF загрузили) — гарант не выдаёт доступ второй раз благодаря защите от двойного гранта (ниже).

Поток оплаты — диаграмма

[Bot] Клиент нажал «Оплатить через Kaspi» │ ├─► pay.proverkacheka.kz POST /v1/invoice/create │ phoneNumber, items=[{name, price, count}], comment │ │ ┌── 200 OK + paymentId ───────────────────────────┐ │ │ │ │ ▼ ▼ │ УСПЕХ: сохранили paymentId, ОШИБКА / TIMEOUT: │ показали «ждём оплату в Kaspi» молча показываем │ legacy-экран: │ • ссылку на Kaspi │ • инструкцию прислать PDF │ ├─► [Webhook от pay.proverkacheka.kz] │ payment.success → mark_bought(user), grant access │ ├─► [Клиент шлёт PDF чек] │ api.proverkacheka.kz POST /upload │ api_key, file=PDF, user_id, price, iin, use_balance=true │ │ ┌── 200 OK, due=0 ───────────────────────────────┐ │ ▼ ▼ │ ОПЛАЧЕНО: mark_bought(user) due > 0: недоплата — │ grant access показать «нужно ещё X ₸» │ └─► Идемпотентность: если webhook уже выдал доступ, PDF-проверка видит bought=1 и не делает второй grant.

Когда срабатывает fallback

Боевая логика (см. ниже) переключается с invoice-flow на PDF-flow в одном из четырёх случаев:

ТриггерЧто показываем клиенту
Бот не знает каноничный номер клиента (нормализация вернула None)Сразу legacy-экран Kaspi-link + PDF
POST /v1/invoice/create вернул не-200 / timeout / connect errorМолча legacy-экран. Админу уходит DM с reason для отладки
В ответе 200 — но нет paymentIdТо же: legacy-экран + DM админу
Локальная БД не сохранила запись об invoice (диск, lock) → нечем сверять webhookBest-effort POST /v1/invoice/{id}/cancel, затем legacy-экран

Реализация: код из боевого бота

Ниже — выжимка из @KarinaKunuspekovaBot (онлайн-курс KK17 BEAUTY). Python 3.11, aiohttp, python-telegram-bot v21.

1. Тонкий клиент для pay.proverkacheka.kz

import aiohttp, asyncio, logging
_TIMEOUT = aiohttp.ClientTimeout(total=8)  # короче спиннера callback'а

async def create_invoice(phone, amount, *, user_id, comment=None,
                         item_name="Курс KK17 BEAUTY"):
    """Push Kaspi Pay invoice. Returns (payload, None) on success,
    (None, reason) on any failure — caller falls back silently."""
    body = {
        "phoneNumber": phone,
        "items": [{"name": item_name, "price": amount, "count": 1}],
    }
    if comment:
        body["comment"] = comment
    headers = {"X-API-Key": API_KEY, "Content-Type": "application/json"}
    try:
        async with aiohttp.ClientSession(timeout=_TIMEOUT) as s:
            async with s.post(f"{API_URL}/v1/invoice/create",
                              headers=headers, json=body) as r:
                payload = await r.json(content_type=None)
                if r.status != 200 or not payload.get("paymentId"):
                    return None, f"HTTP {r.status} — {payload}"
                return payload, None
    except asyncio.TimeoutError:
        return None, "timeout — webhook still may reconcile"
    except Exception as e:
        return None, f"{type(e).__name__}: {e}"

2. Решение «push или fallback»

async def start_kaspi_pay(user_id, phone, amount, ctx, *, tier_key):
    canonical = normalize_phone(phone)
    if not canonical:
        await show_legacy_kaspi_screen(ctx, user_id)        # ← fallback #1
        return

    # Tier-change protection: отмени все pending счета этого юзера ПЕРЕД тем,
    # как пушить новый. Иначе клиент может оплатить старый счёт на другой
    # тариф — выдадите доступ не туда.
    for old in db.list_pending_invoices_for_user(user_id):
        await cancel_invoice(old["payment_id"])             # best-effort
        db.set_invoice_status(old["payment_id"], "failed")

    payload, error = await create_invoice(
        canonical, amount, user_id=user_id,
        comment=f"user {user_id} · {tier_key}",
    )
    if not payload:
        # HTTP 412 (kaspi_session_required) — мерчант перелогинился в Kaspi
        # у себя на телефоне, наша сессия выселена. Никакие retry не помогут —
        # оператор должен пройти /login в @PayProverkaBot. Прокидывайте эту
        # строку в DM админу как есть, чтобы он не гадал, что сломалось.
        await notify_admin_html(f"Kaspi Pay failed: {html_escape(error)}")
        await show_legacy_kaspi_screen(ctx, user_id)        # ← fallback #2
        return

    if not db.save_invoice(payload["paymentId"], user_id,
                            canonical, amount, tier_key=tier_key):
        await cancel_invoice(payload["paymentId"])          # не оставляем ghost
        await show_legacy_kaspi_screen(ctx, user_id)        # ← fallback #3
        return

    await show_pending_invoice_screen(ctx, user_id, canonical, amount)
⚠️ HTTP 412 — kaspi_session_required. Это единственный код ответа от POST /v1/invoice/create, который нельзя молча класть в общий fallback: 412 означает, что наша сессия Kaspi выселена (мерчант перелогинился в приложении Kaspi Pay для бизнеса у себя на телефоне). Никакие retry это не починят — нужна ручная команда /login в @PayProverkaBot. В DM админу пишите эту строку дословно, чтобы оператор не гадал, что именно сломалось.
Tier-change protection. Цикл cancel_invoice по pending-инвойсам перед созданием нового — это страховка от «двойного счёта» в Kaspi у клиента: если он переключил тариф в боте, старый push не должен остаться pay-able. Иначе клиент случайно оплатит счёт на старую сумму, а grant-logic выдаст не тот тариф.

3. Обработка PDF — второй путь

Когда клиент шлёт PDF-чек (Telegram-документ с mime/pdf) — бот скачивает байты и отправляет в api.proverkacheka.kz/upload:

async def verify_receipt(pdf_bytes, user_id, amount, iin, ip_name=None):
    """POST /upload to api.proverkacheka.kz. Returns 'paid', 'partial',
    or 'invalid'."""
    data = aiohttp.FormData()
    data.add_field("api_key", PROVERKACHEKA_API_KEY)
    data.add_field("user_id", str(user_id))
    data.add_field("price",   str(amount))
    data.add_field("iin",     iin)            # ИИН/БИН продавца
    if ip_name:                               # опционально — страховка к ИИН
        data.add_field("ip_name", ip_name)
    data.add_field("use_balance", "true")     # копим частичные оплаты
    data.add_field("max_overpay", "1000")     # терпим до 1000 ₸ переплаты
    data.add_field("max_age", "7")            # чек старше 7 дней — отказ (анти-replay)
    data.add_field("file", pdf_bytes,
                   filename="receipt.pdf",
                   content_type="application/pdf")
    async with aiohttp.ClientSession() as s:
        async with s.post("https://api.proverkacheka.kz/upload",
                          data=data) as r:
            j = await r.json(content_type=None)
            if r.status != 200:
                return "invalid", j
            # due — число (0.0 при use_balance=true и полной оплате); без
            # use_balance Kaspi API возвращает null. Сравнение с 0 покрывает оба.
            if (j.get("due") or 0) == 0:
                return "paid", j
            return "partial", j
Почему use_balance=true. Без этого флага недоплата (например, клиент перевёл 9 800 ₸ вместо 10 000 ₸) возвращается полным отказом. С use_balance=true разница уходит на per-user баланс — и бот может сказать «доплатите 200 ₸», вместо «не оплачено». Подробнее в /docs/#balance.
Production-версия — в AI-Markdown блоке выше. Тот код, что выше, — учебный минимум, чтобы понять схему. В полной версии (нажмите «Скопировать Markdown» в шапке) к этому коду добавлены:

Markdown-блок задуман специально под вставку в AI-агента (Claude Code, Cursor, …) — клиент копирует и получает рабочую интеграцию.

Сверка: webhook + PDF не должны дать двойного гранта

Оба канала могут сработать одновременно: клиент заплатил по invoice push, webhook доставился, бот выдал доступ — а через 30 секунд клиент от усердия ещё и прислал PDF. PDF-верификатор не должен второй раз «купить» курс. Решение строится из девяти слоёв — каждый страхует от своего сценария:

  1. Server-side cross-service dedup (бесплатно, прозрачно). При переходе invoice в Processed, pay.proverkacheka.kz автоматически резервирует check_number = QR<paymentId> на api.proverkacheka.kz. Если клиент потом всё-таки загрузит PDF от того же платежа в /upload — upstream вернёт «Чек уже обработан ранее», а ваш verify_receipt() отправит это в ветку duplicate. Это первая линия защиты, работает без вашего кода и страхует даже если оба канала сработали независимо.
  2. Идемпотентный grant. Функция mark_bought(user_id) в БД возвращает True только при первом переходе 0 → 1; повторные вызовы — False. Любая ветка (webhook / PDF / админский /mark_paid) дёргает одну и ту же функцию. (Для подписочной модели ключ другой — (source, external_id), см. Idempotency models.)
  3. Регистрация по paymentId. Webhook от pay.proverkacheka.kz приносит тот же paymentId, который мы сохранили при create_invoice. Сверка WHERE payment_id = ? однозначно связывает webhook с конкретным юзером.
  4. State-machine на kaspi_pay_invoices.status. Функция set_invoice_status() разрешает только pending → {paid, failed, expired, refunded} и paid → refunded. Если webhook'ом прилетит «опоздавший» payment.success после payment.failed (такое случается — Kaspi иногда дублирует события), переход отклоняется и второй grant не происходит.
  5. Orphan reconciliation по clientMobile. Самый частый «потерянный» платёж: create_invoice упал в клиентский timeout, но сервер всё-таки создал счёт и доставил его клиенту. Клиент платит, webhook прилетает с paymentId, которого у вас в БД нет. Не отвечайте 503 на payment.success безусловно — сначала попробуйте сопоставить по clientMobile (это поле в теле webhook'а, не phoneNumber) и сумме. Если ровно один пользователь с этим телефоном и сумма совпала с одной из ваших цен — авто-grant + админский алерт «orphan reconciled». Несколько совпадений или непонятная сумма — алерт без действия, /grant <user_id> <tier> руками. На orphan-события payment.failed / expired / refunded отвечайте 200 + алерт (никакого стейт-перехода). 503 оставляйте только когда сам бот не подцеплен к webhook-роутеру (misconfig) — тогда есть смысл, чтобы отправитель ретраил с backoff'ом, пока вы чините.
  6. Уже куплено → DM клиенту, а не молчаливый no-op. Дедуп-страж правильно блокирует двойной грант, но если клиент от усердия оплатил повторно — 10 000 ₸ ушло, доступ уже был, никакого подтверждения не пришло. Это выглядит как баг. Скажите ему: «Платёж получен, доступ уже активен.» Опционально — сразу POST /v1/invoice/{id}/refund.
  7. Локальный lock на одновременную проверку PDF. Set _checking_receipt в памяти бота не пускает второй /upload по тому же user_id, пока первый не завершился. Это страхует от двойного нажатия «отправить» в Telegram.
  8. HTML-escape + truncate в админских DM. Тело ошибки от Kaspi может содержать < / > / & — без escape Telegram-парсер HTML молча уронит сообщение, и вы не узнаете об инциденте. Дополнительно режьте на 400 символов: Kaspi иногда возвращает многокилобайтные тела, упирающиеся в лимит 4096 символов Telegram.
  9. check_number с UNIQUE-ограничением — физическая защита от replay. В ответе /upload приходит уникальный check_number для каждого чека Kaspi. Положите его в отдельную таблицу receipts с PRIMARY KEY (check_number) и оборачивайте grant в try/except: при исключении — откатывайте claim, иначе transient-ошибка в активации навсегда «съест» чек, и клиент не сможет переотправить.
Throttling на webhook'е. Если кто-то сканирует ваш endpoint и шлёт мусорные X-Webhook-Signature, каждый mismatch без throttling'а выльется в DM админу. В production-receiver'е (см. Markdown-блок) стоит 1 DM/час ceiling — после первого алерта остальные подавляются.
⚠️ Самый дорогой баг в webhook'е. Поле type в теле всегда равно строке "invoice" (это resource discriminator, не имя события). Имя события лежит в event. Маршрутизация по type == "payment.success" никогда не срабатывает — каждый оплативший клиент остаётся без доступа. Полный пример рабочего receiver'а — в Markdown-гайде (Step 7: «What your webhook will actually receive»).
⚠️ Телефон в webhook'е — clientMobile, не phoneNumber. В запросе POST /v1/invoice/create вы отправляете phoneNumber. А в теле webhook'а и в ответе GET /v1/invoice/{id} то же значение возвращается под именем clientMobile. Если вы копируете имя поля из запроса в обработчик webhook'а — оно всегда будет None, и orphan-reconciliation по телефону (слой 5) молча сломается.

Idempotency models: выберите одну

Главная развилка при копировании этого паттерна в новый бот. Решите ДО написания grant-кода:

МодельКлюч идемпотентностиКогда применять
A — one-time покупка
(курс, e-book, разовый доступ)
user_id
mark_bought() делает 0 → 1 один раз
Юзера нельзя «купить» дважды. Повторный платёж — это сигнал «спросите, не вернуть ли деньги».
B — стэкуемая подписка
(абонемент, продление)
(source, external_id)
source = "kaspi_pay" для webhook'а, "receipt" для PDF
Юзер легитимно может оплатить много раз — каждый раз +30 дней. bought-флага нет, есть starts_at = max(now, prior_expires_at).

Разные source у push и PDF означают, что для физического одного и того же платежа idempotency-ключи разные — и физический cross-source guard в этой модели даёт именно claim_receipt(check_number) (слой 9). Полная реализация обеих моделей — в Markdown-гайде, Step 9.

UX-нюансы

Чек-лист интегратора

  1. API-ключ от @PayProverkaBot + API-ключ от @ProverkaChekakzbot.
  2. ИИН/БИН вашей организации (берётся из любого чека Kaspi).
  3. KASPI_PAY_WEBHOOK_SECRET ≥ 16 символов, отдельный от вашего Telegram WEBHOOK_SECRET; env-вары проверяются на старте (boot fail при пустых).
  4. Boot-проба GET /v1/auth/me на старте бота — громкий лог, если ключ протух / был revoke'нут. Иначе узнаете об этом только через тихие 401 в production.
  5. Реализован create_invoice() с таймаутом ≤ 10 секунд и возвратом (None, reason) на любую ошибку.
  6. HTTP 412 (kaspi_session_required) сурфейсится отдельно — в DM админу пишите строку дословно с подсказкой «/login в @PayProverkaBot». Никакие retry не помогут.
  7. normalize_phone() принимает 10-значный вход (7019009393 — без префикса страны). Официальный API его принимает; ваша валидация не должна резать.
  8. Перед каждым новым push'ем отменяются все pending-инвойсы юзера (list_pending_invoices_for_usercancel_invoice). Иначе клиент после смены тарифа может оплатить старый счёт.
  9. Реализован verify_receipt() с iin=, use_balance=true, max_overpay=1000 и max_age=7 (анти-replay — старые чеки отказываются).
  10. verify_receipt() повторяет transient-ошибки (HTTP 0/429/5xx или retry_after в теле) до 3 раз с backoff'ом. Без этого один hiccup на стороне Kaspi теряет платёж.
  11. verify_receipt() различает 4 исхода: paid / partial / duplicate / invalid. На duplicate пишите клиенту «этот чек уже зачтён ранее», иначе он будет слать его повторно.
  12. Idempotency-модель выбрана осознанно (A или B) и используется из ВСЕХ путей — webhook, PDF, ручной /grant.
  13. Переходы в kaspi_pay_invoices.status только через set_invoice_status(); paid → paid отказывается — защита от «опоздавшего» payment.success.
  14. Таблица receipts с check_number PRIMARY KEY; каждый paid-PDF идёт через claim_receipt()с откатом claim'а при исключении в grant'е, иначе transient-фейл активации навсегда «съест» чек.
  15. Подписаны на webhook payment.success / failed / expired / refunded на pay.proverkacheka.kz (см. /docs/#webhooks).
  16. Webhook-receiver роутится по event["event"], НЕ по event["type"] (см. сверку и Markdown-гайд, Step 7). Опционально — fallback на X-Webhook-Event header и status-field map.
  17. Webhook читает clientMobile из тела (не phoneNumber — это поле запроса, не ответа).
  18. Receiver делает orphan-reconciliation при неизвестном paymentId на payment.success: матч по clientMobile + сумме (точное совпадение с одним тарифом) → авто-grant + админский алерт. Если совпадений 0 или >1 — алерт без действия. 503 оставляйте только когда сам бот не подцеплен к webhook-роутеру (misconfig); на «не нашли paymentId» отвечайте 200.
  19. Receiver логирует X-Webhook-Attempt — без этого отлаживать повторные доставки практически невозможно.
  20. Receiver DM-ит клиента (или делает refund) на повторный платёж, когда is_bought() уже true.
  21. Алерты на mismatch HMAC throttle-ятся (≤1 DM/час) — без этого сканер endpoint'а флудит ваш канал.
  22. Все админские DM HTML-escape'ятся и режутся до 400 символов. Тело Kaspi-ошибки с </>/& без escape молча уронит сообщение в Telegram-парсере.
  23. SQL-схема создана: kaspi_pay_invoices (PK payment_id, опц. tier_key для модели B), users(phone) для orphan-lookup, receipts (PK check_number).
  24. JSON в receiver'е парсится из тех же байтов, что и HMAC — никогда request.json() после request.read().
  25. Кнопка «Поделиться номером» проверяет contact.user_id == message.from_user.id; номер кэшируется — на последующих платежах контакт не запрашивается.
  26. У админа есть /check_invoice <paymentId>, который дёргает GET /v1/invoice/{id} и при status == "Processed" вручную дёргает mark_bought() / activate_or_extend().
  27. Админ получает DM на любую ошибку invoice / неуспешную проверку PDF.
10 самых частых AI-агентских багов при копировании этого паттерна перечислены в Markdown-гайде в разделе «Most common AI-agent bugs» (после Step 10). Если генерируете интеграцию через Claude Code / Cursor / Codex — попросите агента сверить свою реализацию с этим списком ПЕРЕД shipping'ом.

FAQ

Клиент пишет «я оплатил, но бот ничего не понял» — как быстро проверить?

GET /v1/invoice/{paymentId} — это источник правды:

curl -H "X-API-Key: $KASPI_PAY_API_KEY" \
     https://pay.proverkacheka.kz/api/v1/invoice/abc123

Маппинг статусов: Processed = оплачено, RemotePaymentCreated = ещё не оплатил, RemotePaymentRejected = отклонил в Kaspi, RemotePaymentCanceled = отменили вы, RemotePaymentExpired = истёк (~24 ч). Если Processed — webhook не дошёл (или вы его дропнули); дёрните mark_bought() вручную для этого paymentId. 10 строк админ-команды (/check_invoice <paymentId>) закрывают почти все тикеты «застрял платёж» в один тап. Полный пример — в Markdown-гайде, Step 8.

А если оба сервиса в дауне?

Тогда падает только автоматизация. Клиент всё равно может оплатить обычной Kaspi-ссылкой — а вы вручную через /mark_paid <user_id> выдадите доступ, когда сервис вернётся. Чек не теряется: Kaspi присылает PDF на почту клиенту навсегда — позже загрузите его в api.proverkacheka.kz/upload для бухгалтерии.

Можно ли использовать только PDF (без invoice push)?

Да — это самый простой стартовый сценарий. Многие боты так и работают: клиент оплачивает по обычной Kaspi-ссылке, шлёт PDF — бот верифицирует через api.proverkacheka.kz/upload. Invoice push добавляется когда конверсию хочется поднять на 5–15% за счёт «оплата в один тап».

Можно ли использовать только invoice push (без PDF)?

Не рекомендуем. Из ~100 push-оплат типично 3–7 случаев — клиент «не получил уведомление», либо аккаунт Kaspi не на том номере. Если нет PDF-fallback, эти клиенты теряются. Стоимость интеграции второго пути — 30 строк кода (см. выше).

Что с возвратами?

Для invoice push используйте POST /v1/invoice/{id}/refund на pay.proverkacheka.kz. Для PDF-оплат возврат делается вручную в Kaspi Pay для бизнеса — стороны проверки чеков это не касается. Подробнее в /docs/#refund.

Где посмотреть реальную реализацию целиком?

Бот KK17 BEAUTY (@KarinaKunuspekovaBot) — открытая разработка от ИП КУНУСПЕКОВА. Ключевые файлы: bot/kaspi_pay.py (клиент pay.proverkacheka.kz), bot/payments.py (роутинг invoice → fallback), dashboard/routes_payments.py (webhook-приёмник). Свяжитесь с поддержкой @PayProverkaBot за актуальной выгрузкой.

Запустить @PayProverkaBot Та же тема на proverkacheka.kz → Назад к референсу API