Главная › Документация › Fallback PDF-чек
Готовый паттерн · production · KK17 BEAUTYPDF-чек как страховка для Kaspi Pay invoice
Когда основной канал оплаты — это invoice push через
pay.proverkacheka.kz, а проверка PDF-чека через
api.proverkacheka.kz/upload работает в роли резервного
пути. Закрывает 100% оплаченных сделок даже при сбоях push-уведомлений Kaspi,
потерянных webhook'ах или сетевых таймаутах.
Показать содержимое
Проблема: почему один канал ненадёжен
Invoice push (через pay.proverkacheka.kz) — это «оплата в один тап»: вы программно создаёте счёт, клиенту приходит push-уведомление в Kaspi.kz, он подтверждает. Удобно — но завязано на 3 хрупких звена:
- Push-уведомления у клиента отключены — invoice уехал, клиент не видит.
- Kaspi-аккаунт клиента на другом номере, чем тот, что прислал ему оплачивающий сервис.
- Webhook потерялся — клиент оплатил, но ваш приёмник был в этот момент недоступен.
- Таймаут сети при
POST /v1/invoice/create— инвойс может быть создан, но вы об этом не узнали.
Во всех случаях у Kaspi остаётся PDF-чек — он приходит клиенту на почту и доступен из истории Kaspi.kz. PDF — это «бронебойный» источник правды: проверяется по QR, по ИИН/БИН продавца, по сумме. Поэтому он идеально ложится в роль fallback'а:
Основной канал — invoice push (быстро, ноль действий клиента после тапа). Если он отвалился — клиент шлёт PDF, и сервис POST /upload подтверждает оплату по QR. Конверсия не теряется ни на одном из 4 кейсов выше.
Поток оплаты — диаграмма
Реализация 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 версии добавлены:
повторы при 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.
Кнопка «Оплатить через 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 физически не может выдать доступ дважды, даже если все слои выше сломаются. |
Поле type в теле события всегда равно строке "invoice" (resource discriminator). Имя события — event (payment.success / payment.failed / …). Маршрутизация по type == "payment.success" никогда не срабатывает — каждый оплативший клиент остаётся без доступа. Полный рабочий пример — в Markdown-гайде, Step 7.
Чек-лист интегратора
- API-ключ от @ProverkaChekakzbot (10 бесплатных проверок).
- ИИН/БИН вашей организации (берётся из любого вашего чека Kaspi).
-
Реализован
verify_receipt()сiin=,use_balance=true,max_overpay=1000иmax_age=7(анти-replay — старые чеки отказываются). -
verify_receipt()повторяет transient-ошибки (HTTP 0/429/5xx илиretry_afterв теле) до 3 раз с backoff'ом — иначе один hiccup на стороне Kaspi теряет платёж. -
verify_receipt()различает 4 исхода:paid/partial/duplicate/invalid. На дубликате — понятное сообщение клиенту, иначе он будет слать чек повторно. - Идемпотентный
mark_bought()— общий для webhook'а и PDF-обработчика. -
Таблица
receiptsсcheck_number PRIMARY KEY; каждый paid-PDF идёт черезclaim_receipt()— физический анти-replay. - Хэндлер на Telegram-документ с
application/pdf. - Отдельный обработчик «прислали фото-скриншот» → нудим прислать именно PDF.
- Локальный lock
_checking_receiptот двойных вызовов/upload. - (Опционально, для invoice-стороны) клиент
POST /v1/invoice/createи подписка на webhook'и pay.proverkacheka.kz. - 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 за
полной выгрузкой.