Qut PayСайтКабинетБілім базасыAPI (OpenAPI)AI-нұсқаулық

Интеграция с 1С:Предприятие 8.3 (Qut Pay API)

Руководство для платформы 8.3.10 и новее (нужны ХешФункция.SHA256, БуферДвоичныхДанных, ПолучитьДвоичныеДанныеИзСтроки, СоединитьДвоичныеДанные, ПолучитьHexСтрокуИзДвоичныхДанных). Код — обычные серверные процедуры, без внешних компонент и БСП; работает в файловой и клиент-серверной базе. Полная спецификация API: https://api.qut.kz/docs, общие правила — docs/INTEGRATION.md.

Сценарий:

1С (документ «Счёт на оплату») ──POST /api/v1/invoices──▶ Qut Pay ──▶ Kaspi
   │◀── payUrl (ссылка/QR клиенту: SMS, WhatsApp, печатная форма) ─┘
клиент платит в Kaspi
   │◀── webhook invoice.paid (HTTP-сервис 1С, HMAC)    — вариант (b)
   │──▶ GET /api/v1/invoices/{id} по расписанию         — вариант (c)

Что понадобится в конфигурации (названия условные, замените на свои):

ОбъектНазначение
Константа QutPayКлючAPI (Строка 100)API-ключ qp_live_… / qp_test_… (кабинет → API кілттері; для возвратов отметьте scope refunds:write)
Константа QutPayСекретWebhook (Строка 100)Секрет webhook (кабинет → Webhook → добавить адрес; показывается один раз)
Общий модуль QutPayКлиент (Сервер ✔, Вызов сервера ✔ при вызове с клиента)Код из разделов 1, 2.2, 3, 4
Реквизиты документа: QutPayИдентификатор (Строка 64), QutPayСсылка (Строка 500), QutPayСтатус (Строка 30)Хранение счёта Qut Pay
HTTP-сервис QutPay (для варианта b)Приём webhook
Регистр сведений QutPayОбработанныеСобытия (измерения Идентификатор, Статус)Идемпотентность webhook (необязательно, но рекомендуется)

Ключи и секрет лучше хранить не в открытых константах, а в безопасном хранилище БСП (ОбщегоНазначения.ЗаписатьДанныеВБезопасноеХранилище) — здесь для краткости используются константы.


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

Общий модуль QutPayКлиент. Ключевая функция — ПолучитьСсылкуНаОплату, ниже неё — транспорт и JSON-помощники, которые используются и в других разделах.

// ────────────────────────────────────────────────────────────────────
// Общий модуль QutPayКлиент (Сервер)
// ────────────────────────────────────────────────────────────────────

// Создаёт счёт в Qut Pay и возвращает ссылку на оплату.
//
// Параметры:
//   Сумма          - Число  - сумма в тенге, не более 2 знаков после запятой
//   Описание       - Строка - что видит клиент (до 100 символов)
//   НомерДокумента - Строка - номер документа 1С; вернётся в webhook как externalOrderId
//                             и используется как Idempotency-Key (повтор с тем же номером
//                             вернёт тот же счёт, а не создаст новый)
//   Телефон        - Строка - телефон клиента (необязательно). Если указан — попадёт
//                             в customer.phone; чтобы выставить счёт прямо в приложение
//                             Kaspi клиента, передайте ВидСчета = "phone" (сумма целая)
//   ВидСчета       - Строка - "qr" (по умолчанию: ссылка + QR) или "phone"
//   Email          - Строка - email клиента (необязательно; на него уйдёт чек, если у
//                             Qut Pay настроен SMTP)
//
// Возвращаемое значение:
//   Структура - Идентификатор (id счёта "inv_…"), СсылкаНаОплату (payUrl), Статус,
//               QRСсылка, ДействуетДо (строка ISO-8601), Повтор (Истина, если счёт
//               уже существовал по Idempotency-Key)
//
Функция ПолучитьСсылкуНаОплату(Сумма, Описание, НомерДокумента, Телефон = "",
	ВидСчета = "qr", Email = "") Экспорт

	Тело = Новый Структура;
	Тело.Вставить("amount", Окр(Сумма, 2));
	Тело.Вставить("kind", ВидСчета);
	Тело.Вставить("description", Лев(СокрЛП(Описание), 100));
	Тело.Вставить("externalOrderId", СокрЛП(НомерДокумента));

	Клиент = Новый Структура;
	Если ЗначениеЗаполнено(Телефон) Тогда
		Клиент.Вставить("phone", НормализоватьТелефон(Телефон));
	КонецЕсли;
	Если ЗначениеЗаполнено(Email) Тогда
		Клиент.Вставить("email", СокрЛП(Email));
	КонецЕсли;
	Если Клиент.Количество() > 0 Тогда
		Тело.Вставить("customer", Клиент);
	КонецЕсли;

	Метаданные = Новый Структура;
	Метаданные.Вставить("source", "1c");
	Тело.Вставить("metadata", Метаданные);

	Заголовки = Новый Соответствие;
	Заголовки.Вставить("Idempotency-Key", "1c-" + СокрЛП(НомерДокумента));

	Ответ = ВыполнитьЗапрос("POST", "/api/v1/invoices", Тело, Заголовки);

	Результат = Новый Структура;
	Результат.Вставить("Идентификатор",  Ответ.Данные["id"]);
	Результат.Вставить("СсылкаНаОплату", Ответ.Данные["payUrl"]);
	Результат.Вставить("Статус",         Ответ.Данные["status"]);
	Результат.Вставить("QRСсылка",       Ответ.Данные["qrUrl"]);
	Результат.Вставить("ДействуетДо",    Ответ.Данные["expiresAt"]);
	Результат.Вставить("Повтор",         Ответ.Данные["idempotentReplay"] = Истина);
	Возврат Результат;

КонецФункции

// Телефон к формату 7XXXXXXXXXX (только цифры, ведущая 8 → 7).
Функция НормализоватьТелефон(Телефон) Экспорт
	Цифры = "";
	Для Индекс = 1 По СтрДлина(Телефон) Цикл
		Символ = Сред(Телефон, Индекс, 1);
		Если СтрНайти("0123456789", Символ) > 0 Тогда
			Цифры = Цифры + Символ;
		КонецЕсли;
	КонецЦикла;
	Если СтрДлина(Цифры) = 11 И Лев(Цифры, 1) = "8" Тогда
		Цифры = "7" + Сред(Цифры, 2);
	ИначеЕсли СтрДлина(Цифры) = 10 Тогда
		Цифры = "7" + Цифры;
	КонецЕсли;
	Возврат Цифры;
КонецФункции

// ── Транспорт ────────────────────────────────────────────────────────

Функция Настройки()
	Настройки = Новый Структура;
	Настройки.Вставить("Сервер",  "api.qut.kz");
	Настройки.Вставить("Порт",    443);
	Настройки.Вставить("Таймаут", 30);
	Настройки.Вставить("КлючAPI", Константы.QutPayКлючAPI.Получить());
	Возврат Настройки;
КонецФункции

// Выполняет запрос к API. Метод: "GET" | "POST". Тело: Структура/Соответствие или Неопределено.
// Возвращает Структуру: Код (HTTP), Данные (Соответствие из JSON или Неопределено).
// При коде не 2xx вызывает исключение вида "Qut Pay: HTTP 422 invalid_amount: …".
Функция ВыполнитьЗапрос(Метод, Путь, Тело = Неопределено, ДопЗаголовки = Неопределено) Экспорт

	Настройки = Настройки();
	Если ПустаяСтрока(Настройки.КлючAPI) Тогда
		ВызватьИсключение "Qut Pay: не задан API-ключ (константа QutPayКлючAPI)";
	КонецЕсли;

	Заголовки = Новый Соответствие;
	Заголовки.Вставить("X-API-Key",  Настройки.КлючAPI);
	Заголовки.Вставить("Accept",     "application/json");
	Заголовки.Вставить("User-Agent", "qutpay-1c/0.1");
	Если ДопЗаголовки <> Неопределено Тогда
		Для Каждого КлючИЗначение Из ДопЗаголовки Цикл
			Заголовки.Вставить(КлючИЗначение.Ключ, КлючИЗначение.Значение);
		КонецЦикла;
	КонецЕсли;

	Запрос = Новый HTTPЗапрос(Путь, Заголовки);
	Если Метод <> "GET" Тогда
		Запрос.Заголовки.Вставить("Content-Type", "application/json; charset=utf-8");
		ТелоСтрока = ВСтрокуJSON(?(Тело = Неопределено, Новый Структура, Тело));
		Запрос.УстановитьТелоИзСтроки(ТелоСтрока, КодировкаТекста.UTF8, ИспользованиеByteOrderMark.НеИспользовать);
	КонецЕсли;

	Попытка
		ЗащищенноеСоединение = Новый OpenSSLЗащищенноеСоединение();
		Соединение = Новый HTTPСоединение(Настройки.Сервер, Настройки.Порт, , , , Настройки.Таймаут, ЗащищенноеСоединение);
		Если Метод = "GET" Тогда
			Ответ = Соединение.Получить(Запрос);
		Иначе
			Ответ = Соединение.ОтправитьДляОбработки(Запрос);
		КонецЕсли;
	Исключение
		ВызватьИсключение "Qut Pay: сетевая ошибка: " + КраткоеПредставлениеОшибки(ИнформацияОбОшибке());
	КонецПопытки;

	ТелоОтвета = Ответ.ПолучитьТелоКакСтроку();
	Данные = ИзJSON(ТелоОтвета);

	Если Ответ.КодСостояния < 200 Или Ответ.КодСостояния >= 300 Тогда
		КодОшибки = "";
		Сообщение = "";
		Если ТипЗнч(Данные) = Тип("Соответствие") Тогда
			КодОшибки = Строка(Данные["error"]);
			Сообщение = Строка(Данные["message"]);
		КонецЕсли;
		Если ПустаяСтрока(Сообщение) Тогда
			Сообщение = Лев(ТелоОтвета, 300);
		КонецЕсли;
		ВызватьИсключение СтрШаблон("Qut Pay: HTTP %1 %2: %3", Ответ.КодСостояния, КодОшибки, Сообщение);
	КонецЕсли;

	Результат = Новый Структура;
	Результат.Вставить("Код", Ответ.КодСостояния);
	Результат.Вставить("Данные", Данные);
	Возврат Результат;

КонецФункции

// ── JSON ─────────────────────────────────────────────────────────────

Функция ВСтрокуJSON(Значение) Экспорт
	Запись = Новый ЗаписьJSON;
	Запись.УстановитьСтроку(Новый ПараметрыЗаписиJSON(ПереносСтрокJSON.Нет));
	ЗаписатьJSON(Запись, Значение);
	Возврат Запись.Закрыть();
КонецФункции

// Строка JSON → Соответствие (объекты) / Массив / примитив. Пустая или невалидная строка → Неопределено.
// Читаем в Соответствие, а не в Структуру: ключи JSON (например, внутри metadata) могут не быть
// допустимыми идентификаторами 1С.
Функция ИзJSON(Строка) Экспорт
	Если ПустаяСтрока(Строка) Тогда
		Возврат Неопределено;
	КонецЕсли;
	Чтение = Новый ЧтениеJSON;
	Чтение.УстановитьСтроку(Строка);
	Попытка
		Результат = ПрочитатьJSON(Чтение, Истина);
	Исключение
		Результат = Неопределено;
	КонецПопытки;
	Чтение.Закрыть();
	Возврат Результат;
КонецФункции

Пример вызова из модуля документа (серверный контекст):

Процедура ВыставитьСчетQutPay(Документ) Экспорт
	Результат = QutPayКлиент.ПолучитьСсылкуНаОплату(
		Документ.СуммаДокумента,
		"Оплата по счёту № " + Документ.Номер,
		Документ.Номер,
		Документ.ТелефонКонтрагента);
	ДокОбъект = Документ.ПолучитьОбъект();
	ДокОбъект.QutPayИдентификатор = Результат.Идентификатор;
	ДокОбъект.QutPayСсылка        = Результат.СсылкаНаОплату;
	ДокОбъект.QutPayСтатус        = Результат.Статус;   // "pending"
	ДокОбъект.Записать();
	// Дальше: показать ссылку/QR в форме, вставить в печатную форму, отправить в WhatsApp/SMS
КонецПроцедуры

Заметки:


2. Приём webhook через HTTP-сервис

Qut Pay отправляет POST с JSON { "event": "invoice.paid", "invoice": { "id", "externalOrderId", "status", "amount", "paidAt", "receiptUrl", "metadata", … }, "sentAt" } и заголовками:

X-Webhook-Event: invoice.paid
X-Webhook-Timestamp: 1757167000                       (unix, секунды)
X-Webhook-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>
X-Webhook-Delivery: 17

События: invoice.paid, invoice.failed, invoice.expired, invoice.cancelled, invoice.refunded, invoice.partially_refunded, webhook.test. Ответ не-2xx → повтор до 11 раз (10 с → 1 ч), кроме 4xx (кроме 408/429): такие доставки считаются окончательно неудачными. Поэтому: неверная подпись → 401; временная ошибка в 1С (блокировка, недоступна БД) → 503, чтобы Qut Pay повторил.

2.1. Объекты конфигурации

  1. Общие → HTTP-сервисы → Добавить: имя QutPay, Корневой URL qutpay.
  2. Внутри — Шаблон URL Webhook с шаблоном /webhook; в нём Метод POST с обработчиком WebhookPOST.
  3. Администрирование → Публикация на веб-сервере: вкладка HTTP-сервисы → отметить QutPay → Опубликовать (Apache или IIS).
  4. Адрес webhook для кабинета Qut Pay: https://<ваш-хост>/<имя-публикации>/hs/qutpay/webhook.

Аутентификация. HTTP-сервис 1С по умолчанию требует логин/пароль пользователя ИБ, а Qut Pay шлёт запросы без авторизации (логин:пароль в URL адреса указать нельзя — отправитель их не поддерживает). Стандартное решение — отдельная публикация только для webhook, в default.vrd которой пользователь задан в строке подключения:

<point xmlns="http://v8.1c.ru/8.2/virtual-resource-system"
       xmlns:xs="http://www.w3.org/2001/XMLSchema"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       base="/qutpay-hook"
       ib="Srvr=srv1c;Ref=trade;Usr=QutPayWebhook;Pwd=СложныйПароль;">
  <httpServices publishExtensionsByDefault="false">
    <service name="QutPay" rootUrl="qutpay" enable="true"
             reuseSessions="autouse" sessionMaxAge="20" poolSize="10" poolTimeout="5">
      <point name="Webhook" alias="webhook" enable="true"/>
    </service>
  </httpServices>
</point>

Пользователю QutPayWebhook дайте отдельную роль: право Использование на HTTP-сервис QutPay и ничего больше; запись данных выполняйте внутри обработчика под УстановитьПривилегированныйРежим(Истина). Веб-сервер должен отдавать https с валидным сертификатом (в продакшене кабинет принимает только https).

2.2. HMAC-SHA256 на встроенном языке

В 1С нет встроенного HMAC, но есть ХешированиеДанных(ХешФункция.SHA256) и побайтовый доступ через БуферДвоичныхДанных. HMAC по RFC 2104: H((K xor opad) || H((K xor ipad) || message)), размер блока SHA-256 — 64 байта, ipad = 0x36, opad = 0x5C. Добавьте в QutPayКлиент:

// ── HMAC-SHA256 ──────────────────────────────────────────────────────

// HMAC-SHA256 по RFC 2104. Ключ и Сообщение — ДвоичныеДанные. Результат — ДвоичныеДанные (32 байта).
Функция HMACSHA256(Ключ, Сообщение) Экспорт

	РазмерБлока = 64;

	// Ключ длиннее блока — заменяется своим хешем
	КлючДД = Ключ;
	Если КлючДД.Размер() > РазмерБлока Тогда
		ХешКлюча = Новый ХешированиеДанных(ХешФункция.SHA256);
		ХешКлюча.Добавить(КлючДД);
		КлючДД = ХешКлюча.ХешСумма;
	КонецЕсли;
	КлючБуфер = ПолучитьБуферДвоичныхДанных(КлючДД);

	// K xor ipad, K xor opad (ключ дополняется нулями до 64 байт)
	IPad = Новый БуферДвоичныхДанных(РазмерБлока);
	OPad = Новый БуферДвоичныхДанных(РазмерБлока);
	Для Индекс = 0 По РазмерБлока - 1 Цикл
		Байт = ?(Индекс < КлючБуфер.Размер, КлючБуфер[Индекс], 0);
		IPad[Индекс] = XORБайт(Байт, 54);   // 0x36
		OPad[Индекс] = XORБайт(Байт, 92);   // 0x5C
	КонецЦикла;

	// inner = SHA256((K xor ipad) || message)
	Внутренний = Новый ХешированиеДанных(ХешФункция.SHA256);
	Внутренний.Добавить(ПолучитьДвоичныеДанныеИзБуфераДвоичныхДанных(IPad));
	Внутренний.Добавить(Сообщение);

	// outer = SHA256((K xor opad) || inner)
	Внешний = Новый ХешированиеДанных(ХешФункция.SHA256);
	Внешний.Добавить(ПолучитьДвоичныеДанныеИзБуфераДвоичныхДанных(OPad));
	Внешний.Добавить(Внутренний.ХешСумма);

	Возврат Внешний.ХешСумма;

КонецФункции

// Побитовое исключающее ИЛИ двух байтов (0..255) арифметикой — в языке 1С нет битовых операторов.
Функция XORБайт(А, Б)
	Результат = 0;
	Множитель = 1;
	X = А;
	Y = Б;
	Для Бит = 1 По 8 Цикл
		Если X % 2 <> Y % 2 Тогда
			Результат = Результат + Множитель;
		КонецЕсли;
		X = Цел(X / 2);
		Y = Цел(Y / 2);
		Множитель = Множитель * 2;
	КонецЦикла;
	Возврат Результат;
КонецФункции

// Hex-строка (нижний регистр, без разделителей) из ДвоичныхДанных.
Функция ВHex(ДвоичныеДанные) Экспорт
	Возврат НРег(ПолучитьHexСтрокуИзДвоичныхДанных(ДвоичныеДанные));
КонецФункции

// Сравнение строк за время, не зависящее от позиции первого различия (защита от timing-атаки).
Функция СтрокиРавныБезУтечкиВремени(А, Б)
	Если СтрДлина(А) <> СтрДлина(Б) Тогда
		Возврат Ложь;
	КонецЕсли;
	Различий = 0;
	Для Индекс = 1 По СтрДлина(А) Цикл
		Если КодСимвола(А, Индекс) <> КодСимвола(Б, Индекс) Тогда
			Различий = Различий + 1;
		КонецЕсли;
	КонецЦикла;
	Возврат Различий = 0;
КонецФункции

Функция ТолькоЦифры(Строка)
	Если ПустаяСтрока(Строка) Тогда
		Возврат Ложь;
	КонецЕсли;
	Для Индекс = 1 По СтрДлина(Строка) Цикл
		Если СтрНайти("0123456789", Сред(Строка, Индекс, 1)) = 0 Тогда
			Возврат Ложь;
		КонецЕсли;
	КонецЦикла;
	Возврат Истина;
КонецФункции

// Проверка подписи webhook Qut Pay.
//   Секрет       - Строка         - секрет из кабинета
//   ТелоДД       - ДвоичныеДанные - тело запроса КАК ЕСТЬ (Запрос.ПолучитьТелоКакДвоичныеДанные())
//   Подпись      - Строка         - заголовок X-Webhook-Signature ("sha256=…")
//   МеткаВремени - Строка         - заголовок X-Webhook-Timestamp
//   ДопускСек    - Число          - допустимое расхождение часов (по умолчанию 300 с)
Функция ПроверитьПодписьWebhook(Секрет, ТелоДД, Подпись, МеткаВремени, ДопускСек = 300) Экспорт

	Если ПустаяСтрока(Секрет) Или ПустаяСтрока(Подпись) Или ПустаяСтрока(МеткаВремени) Тогда
		Возврат Ложь;
	КонецЕсли;
	Если НРег(Лев(Подпись, 7)) <> "sha256=" Тогда
		Возврат Ложь;
	КонецЕсли;
	Если СтрДлина(МеткаВремени) > 12 Или Не ТолькоЦифры(МеткаВремени) Тогда
		Возврат Ложь;
	КонецЕсли;

	// Защита от повтора: |now - timestamp| <= 300 с. Часы сервера 1С должны быть точными (NTP).
	СейчасUnix = ТекущаяУниверсальнаяДата() - Дата(1970, 1, 1, 0, 0, 0);
	Если Абс(СейчасUnix - Число(МеткаВремени)) > ДопускСек Тогда
		Возврат Ложь;
	КонецЕсли;

	// Сообщение = timestamp + "." + rawBody (байт в байт)
	Части = Новый Массив;
	Части.Добавить(ПолучитьДвоичныеДанныеИзСтроки(МеткаВремени + ".", КодировкаТекста.UTF8, Ложь));
	Части.Добавить(ТелоДД);
	Сообщение = СоединитьДвоичныеДанные(Части);

	КлючДД = ПолучитьДвоичныеДанныеИзСтроки(Секрет, КодировкаТекста.UTF8, Ложь);
	Ожидаемая = ВHex(HMACSHA256(КлючДД, Сообщение));
	Полученная = НРег(СокрЛП(Сред(Подпись, 8)));

	Возврат СтрокиРавныБезУтечкиВремени(Ожидаемая, Полученная);

КонецФункции

Самопроверка HMAC (RFC 4231, тест 2) — выполните один раз в обработке или консоли кода:

Ключ      = ПолучитьДвоичныеДанныеИзСтроки("Jefe", КодировкаТекста.UTF8, Ложь);
Сообщение = ПолучитьДвоичныеДанныеИзСтроки("what do ya want for nothing?", КодировкаТекста.UTF8, Ложь);
Сообщить(QutPayКлиент.ВHex(QutPayКлиент.HMACSHA256(Ключ, Сообщение)));
// Ожидается: 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843

2.3. Обработчик HTTP-сервиса

Модуль HTTP-сервиса QutPay:

Функция WebhookPOST(Запрос)

	Подпись      = ЗначениеЗаголовка(Запрос, "X-Webhook-Signature");
	МеткаВремени = ЗначениеЗаголовка(Запрос, "X-Webhook-Timestamp");
	ТелоДД       = Запрос.ПолучитьТелоКакДвоичныеДанные();

	Секрет = Константы.QutPayСекретWebhook.Получить();
	Если Не QutPayКлиент.ПроверитьПодписьWebhook(Секрет, ТелоДД, Подпись, МеткаВремени) Тогда
		ЗаписьЖурналаРегистрации("QutPay.Webhook", УровеньЖурналаРегистрации.Предупреждение, , ,
			"Неверная подпись webhook, доставка " + ЗначениеЗаголовка(Запрос, "X-Webhook-Delivery"));
		Возврат ОтветJSON(401, "unauthorized");   // 4xx — Qut Pay не будет повторять
	КонецЕсли;

	Событие = QutPayКлиент.ИзJSON(ПолучитьСтрокуИзДвоичныхДанных(ТелоДД, КодировкаТекста.UTF8));
	Если ТипЗнч(Событие) <> Тип("Соответствие") Тогда
		Возврат ОтветJSON(400, "bad_json");
	КонецЕсли;

	Попытка
		УстановитьПривилегированныйРежим(Истина);
		ОбработатьСобытие(Событие);
		УстановитьПривилегированныйРежим(Ложь);
	Исключение
		ЗаписьЖурналаРегистрации("QutPay.Webhook", УровеньЖурналаРегистрации.Ошибка, , ,
			ПодробноеПредставлениеОшибки(ИнформацияОбОшибке()));
		Возврат ОтветJSON(503, "retry");          // 5xx — Qut Pay повторит доставку
	КонецПопытки;

	Возврат ОтветJSON(200, "ok");

КонецФункции

// Заголовки в Запрос.Заголовки — Соответствие; регистр имени зависит от веб-сервера, ищем без учёта регистра.
Функция ЗначениеЗаголовка(Запрос, Имя)
	Для Каждого КлючИЗначение Из Запрос.Заголовки Цикл
		Если НРег(КлючИЗначение.Ключ) = НРег(Имя) Тогда
			Возврат СокрЛП(КлючИЗначение.Значение);
		КонецЕсли;
	КонецЦикла;
	Возврат "";
КонецФункции

Функция ОтветJSON(Код, Текст)
	Ответ = Новый HTTPСервисОтвет(Код);
	Ответ.Заголовки.Вставить("Content-Type", "application/json; charset=utf-8");
	Ответ.УстановитьТелоИзСтроки("{""result"":""" + Текст + """}", КодировкаТекста.UTF8, ИспользованиеByteOrderMark.НеИспользовать);
	Возврат Ответ;
КонецФункции

Процедура ОбработатьСобытие(Событие)

	ИмяСобытия = Строка(Событие["event"]);
	Если ИмяСобытия = "webhook.test" Тогда
		Возврат;   // тестовая кнопка кабинета
	КонецЕсли;

	Счет = Событие["invoice"];
	Если ТипЗнч(Счет) <> Тип("Соответствие") Тогда
		Возврат;
	КонецЕсли;
	Идентификатор  = Строка(Счет["id"]);
	Статус         = Строка(Счет["status"]);           // paid | failed | expired | cancelled | refunded | partially_refunded
	НомерДокумента = Строка(Счет["externalOrderId"]);
	Сумма          = Счет["amount"];
	Поздняя        = (Счет["late"] = Истина);          // оплата после expired/cancelled

	// Идемпотентность: одно событие может прийти повторно. Ключ — пара (id, status).
	Если СобытиеУжеОбработано(Идентификатор, Статус) Тогда
		Возврат;
	КонецЕсли;

	// Ищем документ по сохранённому QutPayИдентификатор (надёжнее, чем по номеру: номера могут повторяться по годам)
	Документ = НайтиДокументПоСчету(Идентификатор);
	Если Документ = Неопределено Тогда
		ЗаписьЖурналаРегистрации("QutPay.Webhook", УровеньЖурналаРегистрации.Предупреждение, , ,
			"Документ для счёта " + Идентификатор + " (" + НомерДокумента + ") не найден");
		ЗапомнитьСобытие(Идентификатор, Статус);
		Возврат;
	КонецЕсли;

	ДокОбъект = Документ.ПолучитьОбъект();
	ДокОбъект.QutPayСтатус = Статус;
	Если ИмяСобытия = "invoice.paid" Тогда
		// ЗАМЕНИТЕ на свою логику: провести документ оплаты, отметить отгрузку, уведомить менеджера.
		// Доступно: Сумма, Счет["paidAt"], Счет["receiptUrl"], Счет["metadata"], Поздняя.
		// При Поздняя = Истина решите: отгрузить или сделать возврат (раздел 4).
	ИначеЕсли ИмяСобытия = "invoice.refunded" Или ИмяСобытия = "invoice.partially_refunded" Тогда
		// Счет["refundedAmount"] — сколько возвращено всего
	КонецЕсли;
	ДокОбъект.Записать();

	ЗапомнитьСобытие(Идентификатор, Статус);

КонецПроцедуры

// Регистр сведений QutPayОбработанныеСобытия (непериодический):
// измерения Идентификатор (Строка 64), Статус (Строка 30).
Функция СобытиеУжеОбработано(Идентификатор, Статус)
	Запрос = Новый Запрос;
	Запрос.Текст =
	"ВЫБРАТЬ ПЕРВЫЕ 1 1
	|ИЗ РегистрСведений.QutPayОбработанныеСобытия КАК Р
	|ГДЕ Р.Идентификатор = &Идентификатор И Р.Статус = &Статус";
	Запрос.УстановитьПараметр("Идентификатор", Идентификатор);
	Запрос.УстановитьПараметр("Статус", Статус);
	Возврат Не Запрос.Выполнить().Пустой();
КонецФункции

Процедура ЗапомнитьСобытие(Идентификатор, Статус)
	МенеджерЗаписи = РегистрыСведений.QutPayОбработанныеСобытия.СоздатьМенеджерЗаписи();
	МенеджерЗаписи.Идентификатор = Идентификатор;
	МенеджерЗаписи.Статус = Статус;
	МенеджерЗаписи.Записать(Истина);
КонецПроцедуры

Функция НайтиДокументПоСчету(Идентификатор)
	Запрос = Новый Запрос;
	Запрос.Текст =
	"ВЫБРАТЬ ПЕРВЫЕ 1 Док.Ссылка
	|ИЗ Документ.СчетНаОплатуПокупателю КАК Док
	|ГДЕ Док.QutPayИдентификатор = &Идентификатор";
	Запрос.УстановитьПараметр("Идентификатор", Идентификатор);
	Выборка = Запрос.Выполнить().Выбрать();
	Если Выборка.Следующий() Тогда
		Возврат Выборка.Ссылка;
	КонецЕсли;
	Возврат Неопределено;
КонецФункции

Проверка из sandbox: добавьте адрес в кабинете, нажмите «Отправить тестовое событие» (webhook.test) — в журнале регистрации не должно быть предупреждений, в кабинете доставка должна быть 200. Затем создайте счёт из 1С тестовым ключом и выполните POST /api/v1/invoices/{id}/simulate {"status":"paid"} (раздел 5) — придёт invoice.paid.


3. Альтернатива: опрос статуса (polling)

Если сервер 1С не доступен из интернета (типичная ситуация), webhook принять нельзя — опрашивайте статус открытых счетов регламентным заданием раз в 1–3 минуты. Ответ GET /api/v1/invoices/{id} — тот же объект счёта плюс events и refunds.

// В общем модуле QutPayКлиент.
// Возвращает Соответствие с полями счёта (id, status, amount, refundedAmount, paidAt, receiptUrl, …).
// Живое = Истина → Qut Pay перед ответом сверится с Kaspi (медленнее и расходует лимит; для регламентной
// проверки не нужно — сервис сам опрашивает Kaspi каждые 3 с, пока счёт открыт).
Функция ПолучитьСостояниеСчета(Идентификатор, Живое = Ложь) Экспорт
	Путь = "/api/v1/invoices/" + КодироватьСтроку(Идентификатор, СпособКодированияСтроки.КодировкаURL);
	Если Живое Тогда
		Путь = Путь + "?live=1";
	КонецЕсли;
	Возврат ВыполнитьЗапрос("GET", Путь).Данные;
КонецФункции

// Поиск счетов по номеру документа (если id не сохранился): GET /api/v1/invoices?externalOrderId=…
Функция НайтиСчетаПоНомеру(НомерДокумента) Экспорт
	Путь = "/api/v1/invoices?externalOrderId=" + КодироватьСтроку(НомерДокумента, СпособКодированияСтроки.КодировкаURL);
	Возврат ВыполнитьЗапрос("GET", Путь).Данные["invoices"];   // Массив Соответствий
КонецФункции

Регламентное задание (метод QutPayКлиент.ОбновитьСтатусыСчетов, расписание — каждые 120 с):

Процедура ОбновитьСтатусыСчетов() Экспорт

	Запрос = Новый Запрос;
	Запрос.Текст =
	"ВЫБРАТЬ Док.Ссылка, Док.QutPayИдентификатор
	|ИЗ Документ.СчетНаОплатуПокупателю КАК Док
	|ГДЕ Док.QutPayИдентификатор <> """"
	|  И Док.QutPayСтатус В (""new"", ""pending"")
	|  И Док.Дата > &ГраницаДаты";
	Запрос.УстановитьПараметр("ГраницаДаты", ТекущаяДатаСеанса() - 3 * 86400);   // не гонять старые

	Выборка = Запрос.Выполнить().Выбрать();
	Пока Выборка.Следующий() Цикл
		Попытка
			Счет = ПолучитьСостояниеСчета(Выборка.QutPayИдентификатор);
		Исключение
			ЗаписьЖурналаРегистрации("QutPay.Опрос", УровеньЖурналаРегистрации.Ошибка, , Выборка.Ссылка,
				КраткоеПредставлениеОшибки(ИнформацияОбОшибке()));
			Продолжить;   // при 429 (request_rate_limited) остальные счета дойдут в следующий запуск
		КонецПопытки;

		Статус = Строка(Счет["status"]);
		Если Статус = "new" Или Статус = "pending" Тогда
			Продолжить;
		КонецЕсли;

		ДокОбъект = Выборка.Ссылка.ПолучитьОбъект();
		ДокОбъект.QutPayСтатус = Статус;
		Если Статус = "paid" Тогда
			// ЗАМЕНИТЕ: та же логика, что и в webhook для invoice.paid (Счет["paidAt"], Счет["receiptUrl"])
		КонецЕсли;
		ДокОбъект.Записать();
	КонецЦикла;

КонецПроцедуры

Статусы: newpending → конечные paid, failed, expired, cancelled; после возврата refunded / partially_refunded. Поздняя оплата: счёт в expired/cancelled может позже стать paid — не исключайте такие документы из опроса сразу, а, например, ещё сутки (в запросе выше добавьте "expired", "cancelled" в список и проверяйте изменение статуса).

Webhook и опрос можно совмещать: webhook — основной канал, опрос раз в 10 минут — страховка.


4. Возвраты

POST /api/v1/invoices/{id}/refund, тело { "amount": 1000, "reason": "…" } (оба поля необязательны; без amount — полный возврат остатка). Нужен API-ключ со scope refunds:write (галочка «Возвраты» при создании ключа в кабинете). Ответ 201 — объект счёта с новым статусом refunded / partially_refunded, полем refundedAmount и вложенным refund: { id, amount, status, … }.

// Возврат по счёту. Сумма = Неопределено → полный возврат остатка.
// Возвращает Структуру: Статус (refunded | partially_refunded), ВозвращеноВсего, ИдентификаторВозврата.
Функция ВернутьПлатеж(Идентификатор, Сумма = Неопределено, Причина = "") Экспорт
	Тело = Новый Структура;
	Если Сумма <> Неопределено Тогда
		Тело.Вставить("amount", Окр(Сумма, 2));
	КонецЕсли;
	Если ЗначениеЗаполнено(Причина) Тогда
		Тело.Вставить("reason", Лев(Причина, 200));
	КонецЕсли;
	Путь = "/api/v1/invoices/" + КодироватьСтроку(Идентификатор, СпособКодированияСтроки.КодировкаURL) + "/refund";
	Данные = ВыполнитьЗапрос("POST", Путь, Тело).Данные;

	Результат = Новый Структура;
	Результат.Вставить("Статус", Данные["status"]);
	Результат.Вставить("ВозвращеноВсего", Данные["refundedAmount"]);
	ДанныеВозврата = Данные["refund"];
	Результат.Вставить("ИдентификаторВозврата", ?(ТипЗнч(ДанныеВозврата) = Тип("Соответствие"), ДанныеВозврата["id"], ""));
	Возврат Результат;
КонецФункции

Ошибки: 409 invoice_not_refundable (счёт не оплачен), 422 invalid_refund_amount (больше остатка), 502 refund_failed (Kaspi отклонил — текст в message), 403 insufficient_scope (у ключа нет refunds:write). Возврат не идемпотентен: не повторяйте вызов автоматически при таймауте — сначала проверьте refundedAmount через ПолучитьСостояниеСчета. Отмена ещё не оплаченного счёта — POST /api/v1/invoices/{id}/cancel без тела (тем же ВыполнитьЗапрос("POST", …)).


5. Тестирование в sandbox

  1. В кабинете переведите организацию в режим sandbox, создайте ключ qp_test_… и запишите его в QutPayКлючAPI. Счета не уходят в Kaspi.
  2. Создайте счёт из 1С (раздел 1) — получите inv_… и payUrl.
  3. Имитируйте оплату из 1С:
// Только sandbox. Статус: "paid" | "failed" | "expired".
Функция ИмитироватьСтатус(Идентификатор, Статус = "paid") Экспорт
	Путь = "/api/v1/invoices/" + КодироватьСтроку(Идентификатор, СпособКодированияСтроки.КодировкаURL) + "/simulate";
	Возврат ВыполнитьЗапрос("POST", Путь, Новый Структура("status", Статус)).Данные;
КонецФункции

или откройте payUrl в браузере и нажмите «[Sandbox] төлеу».

  1. Убедитесь, что документ получил статус paid через webhook (раздел 2) или опрос (раздел 3).
  2. Для продакшена замените ключ на qp_live_… и секрет webhook на секрет live-адреса (у sandbox и live — разные адреса и секреты).

6. На что обратить внимание (непроверенные места)

Код написан по документации платформы без прогона на живой базе 1С — проверьте в синтакс-помощнике вашей версии:


Көмек керек пе? WhatsApp +77788813333 · kazprose@gmail.com · 09:00–21:00 (Алматы)
Кабинеттен де жазуға болады: Қолдау.

Qut Pay — тәуелсіз сервис, «Kaspi Bank» АҚ-мен аффилирленбеген. Kaspi және Kaspi Pay — құқық иесінің тауар белгілері.