Pattern

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

Готовый паттерн · production · KK17 BEAUTY

PDF-чек как страховка для Kaspi Pay invoice

Когда основной канал оплаты — это invoice push через pay.proverkacheka.kz, а проверка PDF-чека через api.proverkacheka.kz/upload работает в роли резервного пути. Закрывает 100% оплаченных сделок даже при сбоях push-уведомлений Kaspi, потерянных webhook'ах или сетевых таймаутах.

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

Проблема: почему один канал ненадёжен

Invoice push (через pay.proverkacheka.kz) — это «оплата в один тап»: вы программно создаёте счёт, клиенту приходит push-уведомление в Kaspi.kz, он подтверждает. Удобно — но завязано на 3 хрупких звена:

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

Идея паттерна

Основной канал — invoice push (быстро, ноль действий клиента после тапа). Если он отвалился — клиент шлёт PDF, и сервис POST /upload подтверждает оплату по QR. Конверсия не теряется ни на одном из 4 кейсов выше.

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

[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.

Реализация PDF-fallback

Ниже — фрагменты из боевого бота @KarinaKunuspekovaBot (онлайн-курс KK17 BEAUTY, ИП КУНУСПЕКОВА). Python 3.11, aiohttp, python-telegram-bot v21.

1. Клиент принимает Telegram-документ с PDF

# bot/payments.py — упрощённо
async def handle_document(update, context):
    doc = update.message.document
    if doc.mime_type != "application/pdf":
        return await update.message.reply_text(
            "Пожалуйста, пришлите файл чека в формате PDF.")
    if doc.file_size > 5 * 1024 * 1024:
        return await update.message.reply_text(
            "Файл больше 5 МБ — Kaspi-чеки обычно ~50 КБ. "
            "Скачайте оригинал из истории Kaspi.")
    f = await doc.get_file()
    pdf_bytes = await f.download_as_bytearray()
    result, payload = await verify_receipt(
        pdf_bytes,
        user_id=update.effective_user.id,
        amount=user_price(update.effective_user.id),
        iin=COMPANY_IIN,
    )
    await route_receipt_result(result, payload, update, context)

2. Тонкий клиент к POST /upload

import aiohttp

async def verify_receipt(pdf_bytes, *, user_id, amount, iin, ip_name=None):
    """Returns ('paid'|'partial'|'invalid', json_payload)."""
    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", bytes(pdf_bytes),
                   filename="receipt.pdf",
                   content_type="application/pdf")
    timeout = aiohttp.ClientTimeout(total=10)
    async with aiohttp.ClientSession(timeout=timeout) 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 API возвращает null. Сравнение с 0 покрывает оба.
            if (j.get("due") or 0) == 0:
                return "paid", j
            return "partial", j

3. Маршрутизация результата

async def route_receipt_result(result, payload, update, context):
    user_id = update.effective_user.id
    if result == "paid":
        if not db.is_bought(user_id):
            db.mark_bought(user_id)             # идемпотентно
            await grant_course_access(context, user_id)
        await update.message.reply_text("✅ Оплата подтверждена.")
    elif result == "partial":
        due = int(payload.get("due", 0))
        await update.message.reply_text(
            f"Чек принят, но не хватает {due:,} ₸. "
            f"Доплатите эту сумму и пришлите новый чек.")
    else:
        await update.message.reply_text(
            f"Чек не прошёл проверку: {payload.get('message','')}. "
            f"Если оплата прошла — напишите в поддержку.")
Production-версия с повторами и анти-replay

Код выше — учебный минимум. В production версии добавлены: повторы при retry_after / 429 / 5xx (до 3 раз), четвёртая ветка ответа duplicate (для уже использованного чека), state-machine на статусах счёта и таблица receipts с check_number PRIMARY KEY как физическая защита от replay. Готовый Markdown-блок (вставить в Claude Code / Cursor) — pay.proverkacheka.kz/docs/fallback/#ai-md.

Куда вписывается invoice push

Кнопка «Оплатить через Kaspi» в боте сначала пробует POST /v1/invoice/create на pay.proverkacheka.kz. Если ответ ≠ 200 или таймаут — бот молча показывает легаси-экран («оплатите по обычной ссылке Kaspi и пришлите PDF»). Полный рецепт invoice-стороны → pay.proverkacheka.kz/docs/fallback/.

use_balance: частичные оплаты

Без флага use_balance=true сумма 9 800 ₸ при цене 10 000 ₸ вернётся как «not_enough» — полный отказ. Клиенту непонятно, что доплатить.

С use_balance=true сервис копит переплаты и недоплаты per-user: если в этом запросе пришло 9 800 ₸, сервис запомнит долг 200 ₸; следующий чек на 300 ₸ → совокупно 10 100 ₸ → оплачено, переплата 100 ₸ останется на счету и пойдёт в зачёт следующей покупки.

В fallback-сценарии это критично: после неудачного invoice клиент часто «оплачивает по памяти» — может перевести округлённую сумму. Без баланса вы превратите это в отказ.

Защита от двойного гранта

Оба канала — webhook от pay.proverkacheka.kz и PDF-загрузка через /upload — могут сработать одновременно. Без защиты вы рискуете выдать доступ дважды (например, удвоить срок подписки).

СлойЧто делает
Server-side cross-service dedup При Processed на invoice'е pay.proverkacheka.kz автоматически резервирует check_number = QR<paymentId> на api.proverkacheka.kz. Если клиент потом всё-таки загрузит PDF от того же платежа в /upload — upstream вернёт «Чек уже обработан ранее». Это работает без вашего кода — первая линия защиты бесплатно.
mark_bought(uid) Проверяет bought=1 и выходит. Любая ветка (webhook / PDF / админский /mark_paid) дёргает одну и ту же функцию.
paymentId Webhook приносит тот же paymentId, что вы получили от POST /v1/invoice/create — однозначная связь webhook ↔ user.
State-machine на kaspi_pay_invoices.status Функция set_invoice_status() разрешает только pending → {paid, failed, expired, refunded} и paid → refunded. Если webhook'ом прилетит «опоздавший» payment.success после payment.failed — переход отклоняется и второй grant не происходит.
Webhook вернул 503, если paymentId ещё не в БД Если клиент платит за <1 секунды (Kaspi уже открыт), webhook прилетает до того, как ваш INSERT закоммитился. Ответ 200 = молча потерянный платёж. Возвращайте 503 — отправитель пойдёт с backoff'ом. Никогда не отвечайте 200 на webhook, который не обработали.
Уже куплено → DM клиенту Дедуп правильно блокирует двойной грант, но «молчаливое ничего» на повторной оплате — это потерянные деньги без подтверждения. DM-ните клиента («оплата получена, доступ уже активен») и опционально POST /v1/invoice/{id}/refund.
Локальный set _checking_receipt Запрещает второй параллельный /upload по тому же user_id — страхует от двойного нажатия «отправить» в Telegram.
check_number + claim_receipt() Поле в ответе /upload. Положите его в таблицу receipts с PRIMARY KEY (check_number) и оборачивайте INSERT в try/except — дубликат ловится IntegrityError. Один PDF физически не может выдать доступ дважды, даже если все слои выше сломаются.
⚠️ Самый дорогой баг в webhook'е

Поле type в теле события всегда равно строке "invoice" (resource discriminator). Имя события — event (payment.success / payment.failed / …). Маршрутизация по type == "payment.success" никогда не срабатывает — каждый оплативший клиент остаётся без доступа. Полный рабочий пример — в Markdown-гайде, Step 7.

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

  1. API-ключ от @ProverkaChekakzbot (10 бесплатных проверок).
  2. ИИН/БИН вашей организации (берётся из любого вашего чека Kaspi).
  3. Реализован verify_receipt() с iin=, use_balance=true, max_overpay=1000 и max_age=7 (анти-replay — старые чеки отказываются).
  4. verify_receipt() повторяет transient-ошибки (HTTP 0/429/5xx или retry_after в теле) до 3 раз с backoff'ом — иначе один hiccup на стороне Kaspi теряет платёж.
  5. verify_receipt() различает 4 исхода: paid / partial / duplicate / invalid. На дубликате — понятное сообщение клиенту, иначе он будет слать чек повторно.
  6. Идемпотентный mark_bought() — общий для webhook'а и PDF-обработчика.
  7. Таблица receipts с check_number PRIMARY KEY; каждый paid-PDF идёт через claim_receipt() — физический анти-replay.
  8. Хэндлер на Telegram-документ с application/pdf.
  9. Отдельный обработчик «прислали фото-скриншот» → нудим прислать именно PDF.
  10. Локальный lock _checking_receipt от двойных вызовов /upload.
  11. (Опционально, для invoice-стороны) клиент POST /v1/invoice/create и подписка на webhook'и pay.proverkacheka.kz.
  12. Production-готовый Markdown для AI-агентаpay.proverkacheka.kz/docs/fallback/#ai-md: копируется один раз, бот собирается без угадывания хелперов.

FAQ

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

Для invoice-стороны (pay.proverkacheka.kz) — 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() вручную. Для PDF-стороны — клиент просто шлёт PDF в бот, и /upload сам подтверждает оплату по QR. Полный пример — в Markdown-гайде, Step 6.

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

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

Что делать, если клиент шлёт скриншот вместо PDF?

Сервис /upload работает только с PDF — QR-код извлекается из исходного файла. Скриншот не подойдёт. Покажите клиенту: «откройте чек в Kaspi → Поделиться → PDF». В KK17 BEAUTY на любое фото бот сразу отвечает этой подсказкой (см. handle_photo_receipt).

Что с возрастом чека?

По умолчанию принимаются чеки до 14 дней давности (параметр max_age). Для fallback-сценария это нормально: если invoice push не дошёл, клиент оплачивает в течение тех же минут — чек свежий. Если ваш бизнес-цикл длиннее (например, абонементы с проверкой раз в месяц), увеличьте max_age.

Что возвращает сервис, если pay.proverkacheka.kz и api.proverkacheka.kz оба ответили «оплачено»?

Сервис проверки чеков ничего не «знает» про invoice push — он просто говорит «чек валидный, due=0». Защиту от двойного гранта вы делаете на своей стороне: mark_bought(uid) идемпотентна, check_number уникален. Подробнее в разделе про дедупликацию.

Где посмотреть полный код примера?

Бот KK17 BEAUTY (@KarinaKunuspekovaBot): bot/payments.py (PDF-обработчик и роутинг), bot/kaspi_pay.py (клиент pay.proverkacheka.kz), dashboard/routes_payments.py (webhook от pay.proverkacheka.kz). Свяжитесь с поддержкой @sanzhar за полной выгрузкой.

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