Документація · Можливості

Каса ПРРО — підпис чеків через невидимий iframe

Готовий приклад для сайту програмного РРО: один імпорт SDK, ключ касира зберігається один раз на зміну, і далі кожен чек підписується КЕП без жодної взаємодії — модалка не відкривається, iframe лишається невидимим. Уся криптографія відбувається у фоновому iframe нашого origin, а ваш код і сервер ключа й пароля не бачать.

Це прикладний сценарій автоматичного режиму: «тиха сесія» (ttlMinutes) перетворює віджет підпису на невидимий фоновий підписувач.

Як це працює — три кроки

Коли Крок Що бачить касир
Раз на робоче місце Зберегти ключ обирає файл ключа, вводить пароль і задає PIN; зашифрований контейнер лишається у сховищі віджета до 30 днів
Раз на зміну Розблокувати PIN-ом перший чек зміни питає лише PIN; далі діє тиха сесія (у прикладі — 480 хв)
Кожен чек Тихий підпис signer.sign(xml) повертає CMS одразу — без модалок, паролів і PIN

1. Підключення віджета

Один імпорт з origin DSTUcrypt — нічого не хоститься у вас, адресу віджета SDK будує сам. Опція session вмикає «запам'ятати ключ»:

import { embed } from 'https://dstucrypt.io/embed/dstucrypt-embed.mjs';

//  Один віджет на сторінку. Ключ+пароль зберігаються під PIN касира;
//  після першого розблокування — 8 годин тихого підпису в цій вкладці.
const signer = await embed('sign', {
  session: { ttlMinutes: 480 },
});

Поки сесія розблокована, sign() виконується тихо: модальне вікно не відкривається, iframe не показується — для касира це схоже на звичайний виклик функції.

2. Одноразове збереження ключа

Окрема кнопка «Зберегти ключ касира» — потрібна один раз на робочому місці (і після зміни ключа):

setupButton.addEventListener('click', async () => {
  await signer.session.setup();
  //  далі: signer.session.status() / lock() / forget() — за потреби
});

3. Підпис чека

XML чека підписується у формат attached CAdES; поле accredited підтверджує, що це саме КЕП (сертифікат від акредитованого надавача):

const { signature, accredited } = await signer.sign(checkXml, {
  format: 'CAdES-BES',
  fileName: `check-${checkNumber}.xml`,
});

if (accredited === false) {
  //  підпис дійсний, але сертифікат не від акредитованого надавача
}

//  signature.base64 — готовий CMS (XML чека + КЕП всередині).
//  '/api/fiscal/checks' — НЕ готовий сервіс, а приклад ендпойнта ВАШОГО
//  бекенда: віджет робить лише криптографію, доставку чека далі реалізуєте ви.
await fetch('/api/fiscal/checks', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ p7s: signature.base64 }),
});

Куди чек іде далі. Підписаний CMS ваш бекенд передає на фіскальний сервер ДПС (ФСКО, fs.tax.gov.ua) — саме він присвоює чеку фіскальний номер. Прямо з браузера цього не зробити (немає CORS, свій протокол обміну), тому між касою і ДПС завжди стоїть ваш сервер: він зберігає чеки, обробляє офлайн-режим ПРРО і повертає касі квитанцію з фіскальним номером для друку.

Обробка помилок

Два коди віджет обробляє сам (показує власну модалку) — їх треба лише проковтнути; решту показуйте касиру:

catch (e) {
  if (e.code === 'not_licensed' || e.code === 'load_failed') return;
  showMessage('Не вдалося підписати чек: ' + e.message);
}

Варіанти сесії

Опція Поведінка Коли доречно
session: true ключ збережено під PIN; PIN питається на кожен підпис максимальний контроль, нечасті операції
session: { ttlMinutes: 480 } PIN один раз — далі тихий підпис до кінця зміни (у межах вкладки) каса ПРРО — рекомендований режим
session: { mode: 'password' } зберігається лише файл ключа; пароль питається щоразу й не зберігається спільні робочі місця
session: { noPin: true } збереження без PIN — підпис ніколи нічого не питає лише довірені кіоски/термінали: захист — тільки обфускація

Збережений ключ прив'язаний до домену вашого сайту й лежить у сховищі origin dstucrypt.io — інші сайти його не дістануть, а ваш код ключа й пароля не бачить ніколи (вони живуть в iframe іншого origin).

Тиха сесія живе у sessionStorage вкладки: нова вкладка після закриття старої знову спитає PIN один раз. На новому домені віджет має 7 днів безкоштовного тріалу, далі — підписка (4 500 грн/міс за домен).

Повний робочий файл

Той самий приклад цілком — можна відкрити на будь-якому сайті як є (у репозиторії: examples/prro-widget-example.html):

<!doctype html>
<html lang="uk">
<head>
  <meta charset="UTF-8" />
  <title>ПРРО: підпис чеків через DSTUcrypt</title>
</head>
<body>
  <h1>Каса ПРРО — підпис чека КЕП</h1>
  <button id="setup-key">Зберегти ключ касира</button>
  <button id="sign-check">Підписати чек</button>
  <p id="status"></p>

  <script type="module">
    import { embed } from 'https://dstucrypt.io/embed/dstucrypt-embed.mjs';

    const status = document.getElementById('status');

    //  Сесія: PIN один раз, далі 8 годин тихого підпису
    const signer = await embed('sign', {
      session: { ttlMinutes: 480 },
    });

    //  Одноразове налаштування робочого місця
    document.getElementById('setup-key').addEventListener('click', async () => {
      try {
        await signer.session.setup();
        status.textContent = 'Ключ збережено. Можна підписувати чеки.';
      } catch (e) { showError(e); }
    });

    //  Ваш XML чека — тут спрощений для прикладу
    let checkNumber = 0;
    function buildCheckXml() {
      checkNumber += 1;
      const now = new Date();
      return [
        '<?xml version="1.0" encoding="UTF-8"?>',
        '<CHECK>',
        '  <CHECKHEAD>',
        '    <DOCTYPE>SaleGoods</DOCTYPE>',
        `    <ORDERNUM>${checkNumber}</ORDERNUM>`,
        `    <ORDERDATE>${now.toISOString().slice(0, 10).replaceAll('-', '')}</ORDERDATE>`,
        `    <ORDERTIME>${now.toTimeString().slice(0, 8).replaceAll(':', '')}</ORDERTIME>`,
        '  </CHECKHEAD>',
        '  <CHECKTOTAL><SUM>123.45</SUM></CHECKTOTAL>',
        '</CHECK>',
      ].join('\n');
    }

    document.getElementById('sign-check').addEventListener('click', async () => {
      try {
        const xml = buildCheckXml();
        const { signature, accredited } = await signer.sign(xml, {
          format: 'CAdES-BES',
          fileName: `check-${checkNumber}.xml`,
        });

        status.textContent = accredited === false
          ? 'Увага: підпис дійсний, але не КЕП.'
          : `Чек № ${checkNumber} підписано (${signature.size} байт).`;

        //  Відправка на ВАШ бекенд ('/api/fiscal/checks' — приклад назви,
        //  ендпойнт реалізуєте ви; далі бекенд передає CMS на фіскальний
        //  сервер ДПС і повертає касі фіскальний номер чека):
        //  await fetch('/api/fiscal/checks', {
        //    method: 'POST',
        //    headers: { 'content-type': 'application/json' },
        //    body: JSON.stringify({ p7s: signature.base64 }),
        //  });
      } catch (e) { showError(e); }
    });

    function showError(e) {
      //  not_licensed / load_failed — модалку вже показав сам віджет
      if (e?.code === 'not_licensed' || e?.code === 'load_failed') return;
      status.textContent = e?.message ?? String(e);
    }
  </script>
</body>
</html>

Довідник інтеграції — Швидкий старт · Підпис · Автоматичний режим.