# Kaspi QR для сайта на Laravel

> Контроллер, который создаёт счёт Qut Pay и отправляет покупателя на payUrl; маршрут вебхука с проверкой HMAC по сырому телу $request->getContent(), исключением из CSRF и идемпотентным обновлением заказа. На PHP SDK или на Http::post, сначала в песочнице.

## Коротко

На Laravel интеграция Kaspi QR через Qut Pay — это два маршрута. Первый создаёт счёт запросом `POST https://api.qut.kz/api/v1/invoices` с заголовком `X-API-Key` и делает `redirect()->away($inv['payUrl'])`. Второй — `POST /qutpay/webhook` в `routes/api.php` (без CSRF): проверяет подпись `X-Webhook-Signature` по **сырому телу** `$request->getContent()`, находит заказ по `externalOrderId`, отмечает его оплаченным один раз и отвечает `200`. Ключ и секрет живут в `.env`, первый прогон — в песочнице с ключом `qp_test_…`.

## Подготовка

1. В кабинете https://qut.kz/app создайте API-ключ (`qp_test_…` для песочницы, `qp_live_…` для боевого режима) и в разделе Интеграции добавьте адрес вебхука `https://site.kz/api/qutpay/webhook`; секрет показывается один раз — [настройка вебхуков](/kb/ru/webhook-setup).
2. В `.env`: `QUTPAY_API_KEY=…` и `QUTPAY_WEBHOOK_SECRET=…`; в `config/services.php`: `'qutpay' => ['key' => env('QUTPAY_API_KEY'), 'webhook_secret' => env('QUTPAY_WEBHOOK_SECRET')]`. Ключ не должен попадать в Blade-шаблоны, JS и репозиторий.
3. Скачайте [PHP SDK](/kb/ru/sdk-php) (https://api.qut.kz/downloads/qutpay-sdk-php.zip) — один файл `src/QutPay.php`, PHP 7.4+, зависимостей нет. Без SDK всё то же делается через `Http::withHeaders(['X-API-Key' => …])->post(…)`.
4. В таблицу заказов добавьте поля `qutpay_invoice_id` и `paid_at`.

## Код: контроллер оплаты и вебхук

```php
// routes/web.php:  Route::post('/orders/{order}/pay', [PayController::class, 'pay']);
// routes/api.php:  Route::post('/qutpay/webhook', [PayController::class, 'webhook']);  // api-группа без CSRF

class PayController extends Controller
{
    public function pay(Order $order)
    {
        $qp  = new \QutPay\Client(config('services.qutpay.key'));
        $inv = $qp->createInvoice([
            'amount'          => $order->total,
            'description'     => "Заказ №{$order->id}",
            'externalOrderId' => (string) $order->id,
            'successUrl'      => route('orders.show', $order),
            'idempotencyKey'  => "order-{$order->id}",   // повтор клика не создаст второй счёт
        ]);
        $order->update(['qutpay_invoice_id' => $inv['id']]);

        return redirect()->away($inv['payUrl']);         // страница с QR и кнопкой «Оплатить в Kaspi»
    }

    public function webhook(Request $request)
    {
        $raw = $request->getContent();                   // сырое тело, НЕ $request->all()
        if (!\QutPay\Webhook::verify(config('services.qutpay.webhook_secret'), $raw, $request->headers->all())) {
            return response()->noContent(401);
        }
        $e = json_decode($raw, true);
        if (($e['event'] ?? '') === 'invoice.paid') {
            $inv = $e['invoice'];
            DB::transaction(function () use ($inv) {
                $order = Order::whereKey($inv['externalOrderId'])->lockForUpdate()->first();
                if ($order && $order->paid_at === null && (float) $inv['amount'] === (float) $order->total) {
                    $order->update(['paid_at' => now(), 'qutpay_invoice_id' => $inv['id']]);
                }
            });
        }
        return response()->noContent(200);                // в течение 8 секунд, иначе повтор
    }
}
```

Без SDK подпись считается так: `hash_equals('sha256=' . hash_hmac('sha256', $request->header('X-Webhook-Timestamp') . '.' . $raw, $secret), $request->header('X-Webhook-Signature', ''))`, плюс отказ, если `X-Webhook-Timestamp` старше 5 минут.

## На что обратить внимание

| Момент | Почему важно |
|---|---|
| Маршрут вебхука в `routes/api.php` | В `web`-группе сработает CSRF и Laravel ответит 419; мы сочтём доставку неуспешной и повторим до 11 раз за ~2 часа. Если маршрут всё же в `web`, добавьте его в исключения `VerifyCsrfToken` |
| `$request->getContent()`, а не `$request->all()` | HMAC считается по байтам; пересобранный JSON не совпадёт никогда — [подпись не сходится](/kb/ru/webhook-signature-mismatch) |
| Идемпотентность | Одно и то же событие может прийти дважды; проверка `paid_at === null` под блокировкой строки делает повтор безвредным — [идемпотентность](/kb/ru/idempotency) |
| Страница «спасибо» | Возврат на `successUrl` — не доказательство оплаты; показывайте «оплачено» по `paid_at` из базы или по `$qp->getInvoice($id, true)` |

Ошибки SDK приходят как `\QutPay\ApiException` с полями `->status` и `->errorCode` (`invalid_amount`, `tariff_limit_reached`, `kaspi_session_expired` …) — [каталог ошибок](/kb/ru/error-catalog).

## Проверка в песочнице

1. С ключом `qp_test_…` откройте `/orders/1/pay` — вас перебросит на страницу оплаты.
2. Имитируйте оплату: `$qp->simulateInvoice($id, 'paid')` или `POST /api/v1/invoices/{id}/simulate` с телом `{"status":"paid"}` — вебхук уходит по-настоящему, с подписью — [песочница](/kb/ru/sandbox-simulate).
3. Убедитесь, что у заказа появился `paid_at`, а повторная доставка (кнопка проверки в кабинете или повтор запроса) ничего не меняет.
4. Переключитесь на `qp_live_…` после подключения кассира — [чек-лист перед запуском](/kb/ru/going-live-checklist).

## Вопросы и ответы

**Можно без SDK, только `Http::post`?** Да: `Http::withHeaders(['X-API-Key' => config('services.qutpay.key'), 'Idempotency-Key' => "order-{$order->id}"])->post('https://api.qut.kz/api/v1/invoices', [...])->json()` — поля те же: `amount`, `externalOrderId`, `description`, `successUrl`, `failUrl`, `metadata` — [все поля счёта](/kb/ru/create-invoice-api).

**Локально вебхук не доходит.** В боевом режиме адрес должен быть `https` на настоящем домене; в песочнице допустимы `http` и адреса туннелей. Журнал доставок — в кабинете, раздел Интеграции.

**Нужен ли polling, если есть вебхук?** На странице ожидания оплаты можно раз в 5–10 секунд спрашивать свой сервер, а он — `GET /invoices/{id}`; это удобство для покупателя, источник истины остаётся вебхук — [вебхук или опрос](/kb/ru/polling-vs-webhook).
