# Node.js SDK

> Установка Node.js-клиента Qut Pay, создание счёта, запрос статуса, возврат и проверка подписи вебхука. Полный рабочий пример на Express с raw body и разбор обработки ошибок.

## Коротко

Node.js SDK — это один ESM-модуль без внешних зависимостей. Работает на Node.js 18 и новее (использует встроенные `fetch` и `node:crypto`). Даёт две вещи: клиент `QutPay` и функцию `verifyWebhook`.

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

SDK необязателен — всё то же самое делается обычным `fetch`. Но проверку подписи лучше взять готовую, чем писать руками.

## Установка

Скачайте архив, распакуйте в папку проекта (например `vendor/qutpay-sdk`) и подключите как локальную зависимость:

```bash
npm install ./vendor/qutpay-sdk
```

Либо просто скопируйте файл `index.js` к себе и импортируйте по пути. Имя пакета — `@qutpay/sdk`, формат — ESM (`"type": "module"`), поэтому в CommonJS-проекте подключайте через `await import('@qutpay/sdk')`.

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

```js
import { QutPay, verifyWebhook } from '@qutpay/sdk';

const qp = new QutPay({
  apiKey: process.env.QUTPAY_API_KEY,   // qp_live_… или qp_test_…
  baseUrl: 'https://api.qut.kz',        // значение по умолчанию, менять не нужно
  timeoutMs: 20000,                     // по умолчанию 20 секунд
});
```

Без `apiKey` конструктор сразу бросит исключение. Ключ должен жить **только на сервере** — не кладите его в браузерный код, в мобильное приложение и в публичный репозиторий.

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

```js
const inv = await 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
});

console.log(inv.id, inv.status, inv.payUrl, inv.expiresAt);
```

В ответе приходят `id`, `status`, `payUrl`, `qrUrl`, `deepLink`, `qrImageUrl`, `expiresAt`. Дальше достаточно отправить покупателя на `inv.payUrl`.

Для `kind: 'phone'` обязателен `customer.phone`, а сумма должна быть целой в тенге.

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

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

```js
const fresh = await qp.getInvoice(inv.id, { live: true });
if (fresh.status === 'paid') await markOrderPaid('4471');

await qp.refundInvoice(inv.id, 5000, 'Часть товара возвращена');
```

В SDK **нет методов для подписок, bulk и форм-хуков.** Их вызывайте напрямую по HTTP:

```js
const resp = await fetch('https://api.qut.kz/api/v1/invoices/bulk', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.QUTPAY_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ invoices: [{ amount: 1000, externalOrderId: 'a-1' }] }),
});
const result = await resp.json(); // HTTP 207, результат по каждому элементу
```

## Проверка подписи вебхука

```js
verifyWebhook({ secret, rawBody, headers, toleranceSec = 300 }) // → true / false
```

`rawBody` — **нетронутые байты или строка тела**. Объект после `JSON.parse` не подойдёт: подпись считается ровно по тем байтам, что пришли. Функция сама читает заголовки `X-Webhook-Signature` и `X-Webhook-Timestamp`, проверяет префикс `sha256=`, отбрасывает доставки старше 5 минут и сравнивает подписи способом, устойчивым к атаке по времени.

## Полный пример на Express

```js
import express from 'express';
import { QutPay, verifyWebhook } from '@qutpay/sdk';

const app = express();
const qp = new QutPay({ apiKey: process.env.QUTPAY_API_KEY });

// 1) Вебхук — объявлен ДО подключения express.json()
app.post('/qutpay-webhook', express.raw({ type: '*/*' }), async (req, res) => {
  const ok = verifyWebhook({
    secret: process.env.QUTPAY_WEBHOOK_SECRET,
    rawBody: req.body,          // Buffer
    headers: req.headers,
  });
  if (!ok) return res.sendStatus(401);

  const { event, invoice } = JSON.parse(req.body.toString('utf8'));

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

  res.sendStatus(200); // без 2xx доставка повторится 11 раз
});

// 2) JSON — для всех остальных маршрутов
app.use(express.json());

app.post('/buy', async (req, res) => {
  const order = await createOrder(req.body);
  try {
    const inv = await qp.createInvoice({
      amount: order.total,
      description: `Заказ №${order.id}`,
      externalOrderId: String(order.id),
      successUrl: `https://site.kz/orders/${order.id}`,
      idempotencyKey: `order-${order.id}`,
    });
    await saveInvoiceId(order.id, inv.id);
    res.redirect(303, inv.payUrl);
  } catch (err) {
    console.error('qutpay', err.status, err.code, err.message);
    res.status(502).render('pay-error');
  }
});

app.listen(3000);
```

Самая частая ошибка — `express.json()`, объявленный раньше маршрута вебхука. Тогда `req.body` превращается в объект, и подпись не сойдётся никогда.

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

На каждый неуспешный ответ SDK бросает `Error` с двумя дополнительными полями:

| Поле | Значение |
|---|---|
| `err.status` | HTTP-статус. При сетевой ошибке и таймауте — `0` |
| `err.code` | Машинный код: `invalid_amount`, `invoice_not_found`, `request_rate_limited`… При таймауте `timeout`, при обрыве сети `network_error` |
| `err.message` | Текст для человека |

```js
try {
  await qp.createInvoice({ amount, externalOrderId });
} catch (err) {
  switch (err.code) {
    case 'kaspi_session_expired':
      notifyAdmin('Привязка кассира оборвалась');
      break;
    case 'tariff_limit_reached':
    case 'tariff_daily_burst':
      notifyAdmin('Лимит');
      break;                      // повторять бессмысленно
    case 'request_rate_limited':
    case 'timeout':
    case 'network_error':
      await retryLater();         // бэкофф 1, 2, 4, 8 секунд
      break;
    default:
      throw err;
  }
}
```

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

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

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

**Заработает в CommonJS-проекте?** Да: `const { QutPay } = await import('@qutpay/sdk');`.

**Есть ли типы для TypeScript?** Отдельного `.d.ts` нет, в модуле есть JSDoc-аннотации — типы придётся описать самому.

**Как получить raw body в Fastify или Koa?** В Fastify — `addContentTypeParser('*', { parseAs: 'buffer' }, …)`, в Koa вместо `koa-bodyparser` используйте `raw-body`. Главное условие одно: получить тело нетронутым.

**С чего начать тестирование?** Создайте счёт тестовым ключом и вызовите `qp.simulateInvoice(id, 'paid')` — вебхук при этом уходит по-настоящему: [Симуляция оплаты в песочнице](/kb/ru/sandbox-simulate).
