# Симуляция оплаты в песочнице

> Эндпоинт simulate меняет статус счёта в песочнице: paid, failed, expired. Kaspi не вызывается, вебхук приходит как при настоящей оплате. Цикл проверки и написание автотестов.

## Коротко

В песочнице настоящего Kaspi нет, поэтому оплату покупателя вы **имитируете сами**:

```
POST /api/v1/invoices/{id}/simulate
{ "status": "paid" }
```

Дальше счёт ведёт себя как при реальной оплате: меняется статус, присваивается номер чека, пишутся события и **вебхук уходит точно так же, как в бою**. Это позволяет прогнать интеграцию целиком, не рискуя ни одним тенге.

Работает **только в песочнице**. В боевом режиме вернётся ошибка `not_sandbox` (HTTP 403).

## В какие статусы можно перевести

| `status` | Что произойдёт |
|---|---|
| `paid` | Счёт помечается оплаченным, выдаётся номер чека, уходит `invoice.paid` |
| `failed` | Счёт закрывается как неоплаченный, уходит `invoice.failed` |
| `expired` | Счёт закрывается по сроку, уходит `invoice.expired` |

Любое другое значение даст `invalid_status` (HTTP 422).

Перевод применяется к **открытому** счёту (`new`, `pending`). Повторная симуляция по уже закрытому счёту вернёт `invoice_not_open` (HTTP 409).

**Есть одно исключение:** счёт в статусе `cancelled` или `expired` можно перевести в `paid`. Это намеренное воспроизведение **поздней оплаты** — ситуации, когда счёт уже закрыт, а деньги пришли позже. Событие тогда приходит с признаком `late: true`, ровно как в продакшене. Так и проверяют, что обработчик встречает этот случай правильно: [Поздняя оплата](/kb/ru/late-payment).

## Что нужно

- Организация должна быть **в режиме песочницы** (переключается в кабинете);
- ключ должен быть вида `qp_test_…`;
- у ключа должно быть право `invoices:write`.

Кассир Kaspi для песочницы не требуется: [Чем песочница отличается от боевого режима](/kb/ru/sandbox-vs-live).

**Kaspi при этом не вызывается вообще.** Никаких SMS, настоящих QR и движения денег. Счета песочницы не входят в месячный лимит тарифа и не запускают пробный период.

## Как сделать вручную

Есть три способа, результат одинаковый.

**Через API:**

```bash
curl -X POST https://api.qut.kz/api/v1/invoices/inv_…/simulate \
  -H 'X-API-Key: qp_test_…' \
  -H 'Content-Type: application/json' \
  -d '{"status":"paid"}'
```

В ответ приходит сам обновлённый счёт: `status`, `paidAt`, `receiptNumber`, `receiptUrl` и остальные поля.

**Со страницы оплаты:** откройте `payUrl` счёта песочницы — там есть кнопка, имитирующая оплату. Самый быстрый путь при ручной проверке.

**Из кабинета:** в разделе Счета откройте карточку счёта — у счёта песочницы доступно действие симуляции.

## Цикл проверки

Короткий порядок проверки интеграции:

1. **Создайте счёт.** `POST /api/v1/invoices` ключом `qp_test_…`. Сохраните `id` и `payUrl` из ответа.
2. **Убедитесь, что адрес вебхука зарегистрирован.** Кабинет → Интеграции. В песочнице требования к адресу мягче, но он должен быть доступен.
3. **Сделайте симуляцию.** `POST /invoices/{id}/simulate` с `{"status":"paid"}`.
4. **Проверьте, что вебхук дошёл.** В своём журнале: пришло ли `invoice.paid`, прошла ли проверка подписи, ответили ли вы 2xx.
5. **Перечитайте счёт.** `GET /invoices/{id}` — должен быть `status: "paid"` и заполненный `receiptNumber`.
6. **Повторите для остальных веток:** новый счёт → `failed`, ещё один → `expired`, ещё один отмените и затем переведите в `paid` (поздняя оплата).

Если вебхук не приходит, причина обычно не в симуляции: [Вебхук не приходит](/kb/ru/webhook-not-arriving).

## Автотесты

Симуляция и сделана ради автотестов. Каркас одного теста:

```javascript
const API = 'https://api.qut.kz/api/v1';
const H = { 'X-API-Key': process.env.QUTPAY_TEST_KEY, 'Content-Type': 'application/json' };

// 1. счёт
const inv = await (await fetch(`${API}/invoices`, {
  method: 'POST', headers: { ...H, 'Idempotency-Key': `test-${Date.now()}` },
  body: JSON.stringify({ amount: 100, description: 'Тест', externalOrderId: 'T-1' }),
})).json();

// 2. имитируем оплату
await fetch(`${API}/invoices/${inv.id}/simulate`, {
  method: 'POST', headers: H, body: JSON.stringify({ status: 'paid' }),
});

// 3. проверяем результат
const after = await (await fetch(`${API}/invoices/${inv.id}`, { headers: H })).json();
if (after.status !== 'paid') throw new Error(`ожидался paid, пришёл ${after.status}`);
```

Если хотите проверять и вебхук, есть два подхода:

- **Напрямую.** Тест сам поднимает приёмник (локальный сервер) и ждёт события после симуляции. Учтите, что доставка происходит не мгновенно, — задайте разумный таймаут.
- **Косвенно.** Вебхук проверяете отдельно, а в тесте гоняете только логику своего обработчика: передаёте ему готовый payload и смотрите, что он делает. Так быстрее и стабильнее.

Чтобы тест был повторяемым, соблюдайте два условия: на каждом прогоне создавайте **новый счёт** (один и тот же нельзя оплатить дважды) и меняйте значение `Idempotency-Key`.

## Ошибки

| Ошибка | HTTP | Причина |
|---|---|---|
| `not_sandbox` | 403 | Организация в боевом режиме или счёт боевой |
| `invalid_status` | 422 | В `status` допустимы только `paid`, `failed`, `expired` |
| `invoice_not_open` | 409 | Счёт уже закрыт (и это не случай поздней оплаты в `paid`) |
| `invoice_not_found` | 404 | Неверный идентификатор или счёт чужой организации |
| `insufficient_scope` | 403 | У ключа нет права `invoices:write` |

Полный список: [Каталог ошибок](/kb/ru/error-catalog).

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

**Можно ли симулировать боевой счёт?** Нет, никогда. В боевом режиме эндпоинт возвращает `not_sandbox`, и это сделано намеренно.

**Вебхук после симуляции такой же, как настоящий?** Да, полностью: те же имена событий, та же схема подписи, тот же порядок повторов при ответе не-2xx.

**Получает ли счёт песочницы чек?** Да, номер чека присваивается и страница чека создаётся, но на ней стоит пометка `SANDBOX`. Фискальный чек при этом не выбивается.

**Можно ли симулировать возврат?** Отдельная симуляция не нужна: по счёту, доведённому в песочнице до `paid`, вызывайте обычный возврат.

**Подойдёт ли этот цикл для ИИ-агента?** Да, именно он удобен агенту для самопроверки: [Поручить интеграцию ИИ-агенту](/kb/ru/ai-agent-setup).
