Интеграция с 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
КонецПроцедуры
Заметки:
- Idempotency-Key. Ключ
1c-<номер>привязан к номеру документа: повторное нажатие «Выставить счёт» вернёт тот же счёт (HTTP 200,Повтор = Истина). Если документ перевыставляется с другой суммой, включите в ключ версию или сумму ("1c-" + Номер + "-" + Формат(Сумма, "ЧРД=.; ЧГ=0")), иначе получите старый счёт. - Срок жизни.
ДействуетДо— окно сканирования QR у Kaspi (~3 минуты). ПоказывайтеpayUrl, а не сам QR: страница Qut Pay сама обновляет QR, пока счёт открыт. - Ошибки. Функция бросает исключение с кодом ошибки API (
invalid_amount,invalid_phone,phone_required,amount_must_be_whole_tenge,tariff_limit_reached,kaspi_session_expired,request_rate_limited…) — ловитеПопытка … Исключениеи показывайте пользователюОписаниеОшибки(). - Сертификаты.
OpenSSLЗащищенноеСоединение()без параметров на Windows использует хранилище Windows, на Linux — системные CA-сертификаты (ca-certificatesдолжны быть установлены на сервере 1С).
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. Объекты конфигурации
- Общие → HTTP-сервисы → Добавить: имя
QutPay, Корневой URLqutpay. - Внутри — Шаблон URL
Webhookс шаблоном/webhook; в нём МетодPOSTс обработчикомWebhookPOST. - Администрирование → Публикация на веб-сервере: вкладка HTTP-сервисы → отметить
QutPay→ Опубликовать (Apache или IIS). - Адрес 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"])
КонецЕсли;
ДокОбъект.Записать();
КонецЦикла;
КонецПроцедуры
Статусы: new → pending → конечные 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
- В кабинете переведите организацию в режим sandbox, создайте ключ
qp_test_…и запишите его вQutPayКлючAPI. Счета не уходят в Kaspi. - Создайте счёт из 1С (раздел 1) — получите
inv_…иpayUrl. - Имитируйте оплату из 1С:
// Только sandbox. Статус: "paid" | "failed" | "expired".
Функция ИмитироватьСтатус(Идентификатор, Статус = "paid") Экспорт
Путь = "/api/v1/invoices/" + КодироватьСтроку(Идентификатор, СпособКодированияСтроки.КодировкаURL) + "/simulate";
Возврат ВыполнитьЗапрос("POST", Путь, Новый Структура("status", Статус)).Данные;
КонецФункции
или откройте payUrl в браузере и нажмите «[Sandbox] төлеу».
- Убедитесь, что документ получил статус
paidчерез webhook (раздел 2) или опрос (раздел 3). - Для продакшена замените ключ на
qp_live_…и секрет webhook на секрет live-адреса (у sandbox и live — разные адреса и секреты).
6. На что обратить внимание (непроверенные места)
Код написан по документации платформы без прогона на живой базе 1С — проверьте в синтакс-помощнике вашей версии:
БуферДвоичныхДанных: чтение/запись байта черезБуфер[Индекс]и свойствоРазмер(8.3.10+). Если запись через индекс недоступна, используйте методы буфераЗаписатьПобитовоеИсключительноеИли(Позиция, Байты)(есть такжеЗаписатьПобитовоеИ,ЗаписатьПобитовоеИли): заполните буфер константой 0x36/0x5C и примените XOR с ключом, дополненным нулями до 64 байт — тогдаXORБайтне нужен.ПолучитьHexСтрокуИзДвоичныхДанных(8.3.10+). Если функции нет —НРег(СтрЗаменить(Строка(ДвоичныеДанные), " ", ""))(строковое представление ДвоичныхДанных — hex с пробелами).ХешированиеДанных.Добавитьнакапливает данные при нескольких вызовах (используется в HMAC). Если в вашей версии это не так — склейте части черезСоединитьДвоичныеДанныеи вызовитеДобавитьодин раз.ПолучитьДвоичныеДанныеИзСтроки(Строка, Кодировка, ДобавитьBOM)— третий параметр обязательноЛожь: BOM в ключе или сообщении сломает подпись.- Регистр имён заголовков в
HTTPСервисЗапрос.Заголовкизависит от веб-сервера (Apache отдаёт как прислано, IIS может изменить) — поэтому поиск без учёта регистра. ПолучитьТелоКакДвоичныеДанные()иПолучитьТелоКакСтроку()на одном запросе: в примере строка декодируется из уже полученных двоичных данных, чтобы не читать тело дважды.- Анонимный доступ к HTTP-сервису через
Usr/Pwdвibфайлаdefault.vrd— стандартный, но зависящий от вашей публикации приём; на IIS/Apache проверьте, что для этой публикации отключена basic-аутентификация веб-сервера. OpenSSLЗащищенноеСоединение()без параметров: на Linux-сервере 1С должны быть установлены системные CA-сертификаты, иначе будет ошибка проверки сертификата api.qut.kz.- Время: проверка
±300 сиспользуетТекущаяУниверсальнаяДата()— часы сервера 1С (или веб-сервера при файловой базе) должны синхронизироваться по NTP. - Имя документа
СчетНаОплатуПокупателюи реквизитыQutPay*— условные, подставьте свои.
Көмек керек пе? WhatsApp +77788813333 · kazprose@gmail.com · 09:00–21:00 (Алматы)
Кабинеттен де жазуға болады: Қолдау.
Qut Pay — тәуелсіз сервис, «Kaspi Bank» АҚ-мен аффилирленбеген. Kaspi және Kaspi Pay — құқық иесінің тауар белгілері.