# PHP SDK

> Как подключить PHP-клиент Qut Pay, создать счёт, сделать возврат и проверить подпись вебхука. Примеры на чистом PHP и на Laravel, правильное получение raw body и обработка ApiException.

## Коротко

PHP SDK — это один файл: `src/QutPay.php`. Работает на PHP 7.4 и новее с расширениями `curl` и `json`, других зависимостей нет. Подключается и через Composer, и обычным `require`.

Внутри три класса:

| Класс | Для чего |
|---|---|
| `\QutPay\Client` | Вызовы API: создать счёт, прочитать, отменить, вернуть |
| `\QutPay\Webhook` | Проверка подписи входящего уведомления |
| `\QutPay\ApiException` | Ошибка: `->status` (HTTP) и `->errorCode` (машинный код) |

Архив: **https://api.qut.kz/downloads/qutpay-sdk-php.zip**

## Установка

Самый простой путь — скопировать `src/QutPay.php` из архива к себе и подключить напрямую:

```php
require __DIR__ . '/vendor/qutpay/QutPay.php';
```

Если пользуетесь Composer, распакуйте архив в отдельную папку и подключите как локальный репозиторий:

```json
{
  "repositories": [{ "type": "path", "url": "./vendor-local/qutpay-sdk" }],
  "require": { "qutpay/sdk": "*" }
}
```

Имя пакета — `qutpay/sdk`, автозагрузка PSR-4 настроена на пространство имён `QutPay\`.

## Инициализация

```php
$qp = new \QutPay\Client(getenv('QUTPAY_API_KEY'));       // baseUrl по умолчанию https://api.qut.kz
// При необходимости: new \QutPay\Client($key, 'https://api.qut.kz', 20);  // третий аргумент — таймаут в секундах
```

С пустым ключом конструктор бросит `InvalidArgumentException`. Не держите ключ в коде — читайте его из переменной окружения или конфига, который не попадает в репозиторий.

## Создание счёта

```php
$inv = $qp->createInvoice([
    'amount'          => 12500,
    'kind'            => 'qr',              // 'qr' (по умолчанию) или 'phone'
    'description'     => 'Заказ №4471',
    'externalOrderId' => '4471',
    'customer'        => ['name' => 'Айгуль', 'phone' => '77011234567', 'email' => 'a@b.kz'],
    'successUrl'      => 'https://site.kz/ok',
    'failUrl'         => 'https://site.kz/fail',
    'metadata'        => ['branch' => 'almaty-abay'],
    'idempotencyKey'  => 'order-4471',      // уходит в заголовок Idempotency-Key
]);

header('Location: ' . $inv['payUrl']);
exit;
```

Ответ — ассоциативный массив: `id`, `status`, `payUrl`, `qrUrl`, `deepLink`, `qrImageUrl`, `expiresAt`.

## Остальные методы

| Метод | Что делает |
|---|---|
| `$qp->getInvoice($id)` | Читает счёт. `$qp->getInvoice($id, true)` дополнительно опрашивает Kaspi и обновляет статус |
| `$qp->listInvoices(['status' => 'paid', 'limit' => 50])` | Список. Фильтры: `status`, `externalOrderId`, `search`, `from`, `to`, `limit`, `offset` |
| `$qp->cancelInvoice($id)` | Отменяет открытый счёт |
| `$qp->refundInvoice($id, $amount = null, $reason = null)` | Возврат. `$amount = null` — полный |
| `$qp->simulateInvoice($id, 'paid')` | Только песочница: имитация оплаты |
| `$qp->account()` | Краткая информация об организации — удобно для проверки ключа |

```php
$fresh = $qp->getInvoice($inv['id'], true);
if ($fresh['status'] === 'paid') {
    markOrderPaid('4471');
}

$qp->refundInvoice($inv['id'], 5000, 'Часть товара возвращена');
```

В SDK **нет методов для подписок, bulk и форм-хуков.** Вызывайте их напрямую через curl — эндпоинты описаны на [api.qut.kz/docs](https://api.qut.kz/docs).

## Вебхук: чистый PHP

```php
<?php
require __DIR__ . '/QutPay.php';

$secret = getenv('QUTPAY_WEBHOOK_SECRET');
$raw    = file_get_contents('php://input');   // нетронутое тело

if (!\QutPay\Webhook::verify($secret, $raw, getallheaders())) {
    http_response_code(401);
    exit;
}

$e       = json_decode($raw, true);
$event   = $e['event'] ?? '';
$invoice = $e['invoice'] ?? [];

// Идемпотентность: событие может прийти дважды
if (!alreadyHandled($invoice['id'], $invoice['status'])) {
    if ($event === 'invoice.paid')     markOrderPaid($invoice['externalOrderId'], $invoice);
    if ($event === 'invoice.refunded') markOrderRefunded($invoice['externalOrderId']);
    rememberHandled($invoice['id'], $invoice['status']);
}

http_response_code(200);   // без 2xx доставка повторится 11 раз
```

`Webhook::verify($secret, $rawBody, $headers, $toleranceSec = 300)` ищет заголовки без учёта регистра, проверяет префикс `sha256=`, отбрасывает доставки старше 5 минут и сравнивает через `hash_equals`.

В некоторых конфигурациях FastCGI функции `getallheaders()` нет. Тогда соберите заголовки вручную:

```php
$headers = [
    'X-Webhook-Signature' => $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
    'X-Webhook-Timestamp' => $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '',
];
```

## Вебхук: Laravel

В Laravel тело берите через `$request->getContent()` — он возвращает исходную строку. `$request->all()` **не подходит**.

```php
// routes/api.php
Route::post('/qutpay-webhook', [QutPayController::class, 'handle']);
```

```php
// app/Http/Controllers/QutPayController.php
use Illuminate\Http\Request;

class QutPayController extends Controller
{
    public function handle(Request $request)
    {
        $raw = $request->getContent();
        $ok  = \QutPay\Webhook::verify(
            config('services.qutpay.webhook_secret'),
            $raw,
            $request->headers->all()        // значения массивами — verify это понимает
        );
        if (!$ok) {
            return response()->noContent(401);
        }

        $e = json_decode($raw, true);
        ProcessQutPayEvent::dispatch($e);   // тяжёлую работу — в очередь

        return response()->noContent(200);
    }
}
```

Не забудьте про две вещи:

1. **CSRF.** Держите маршрут в `routes/api.php` либо добавьте его в `$except` у `VerifyCsrfToken` — иначе Laravel вернёт 419, а мы посчитаем доставку неуспешной и начнём повторять.
2. **Быстрый ответ.** Тяжёлую логику (письма, пересчёт отчётов) уводите в очередь, а 2xx возвращайте сразу.

В контроллере создания счёта:

```php
$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}",
]);

return redirect()->away($inv['payUrl']);
```

## Обработка ошибок

На любой неуспешный ответ SDK бросает `\QutPay\ApiException`:

| Поле | Значение |
|---|---|
| `->status` | HTTP-статус. При сетевой ошибке — `0` |
| `->errorCode` | Машинный код: `invalid_amount`, `invoice_not_found`, `request_rate_limited`… При обрыве сети `network_error` |
| `->getMessage()` | Текст для человека |

```php
try {
    $inv = $qp->createInvoice([...]);
} catch (\QutPay\ApiException $e) {
    switch ($e->errorCode) {
        case 'kaspi_session_expired':
            notifyAdmin('Привязка кассира оборвалась');
            break;
        case 'tariff_limit_reached':
        case 'tariff_daily_burst':
            notifyAdmin('Лимит');          // повторять бессмысленно
            break;
        case 'request_rate_limited':
        case 'network_error':
            retryLater();                   // бэкофф 1, 2, 4, 8 секунд
            break;
        default:
            report($e);
    }
    abort(502);
}
```

Решение принимайте по `errorCode`: текст может измениться, код — нет. Полный список: [Каталог ошибок](/kb/ru/error-catalog).

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

**Можно поставить из Packagist?** Нет, пакет не опубликован в публичном реестре. Скачайте архив и подключите как репозиторий типа `path` либо просто скопируйте файл.

**Нужен ли Guzzle?** Нет. SDK использует только расширение `curl`.

**У меня магазин на WordPress, нужен ли этот SDK?** Нет. Для WooCommerce есть готовый плагин: [Плагин WooCommerce](/kb/ru/woocommerce).

**Как получить raw body в Symfony?** Так же, как в Laravel: `$request->getContent()` и `$request->headers->all()`.

**Подпись не сходится.** Значит тело где-то изменилось: не пересобирайте его через json_decode/json_encode, проверьте, что кеш и защитные плагины не трогают тело запроса. Подробнее: [Подпись вебхука не сходится](/kb/ru/webhook-signature-mismatch).
