Setup Kaspi POS mini service

This commit is contained in:
tjunussov 2026-07-18 11:59:37 +00:00
parent ed05d72ed6
commit 5924c1c635
43 changed files with 8095 additions and 1 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

18
.env.example Normal file
View File

@ -0,0 +1,18 @@
TOKEN_SECRET_KEY=
PORT=3000
# ─── App version & device constants (optional) ───
# Defaults match a known-good Kaspi Pay client.
# ⚠️ The Kaspi API validates these — use values from a real device cookie.
# APP_VERSION=4.110.1
# APP_BUILD=1099
# APP_PLATFORM=iOS
# APP_PLATFORM_VER=18.5
# APP_LOCALE=ru-RU
# APP_MODEL=iPhone17,3
# APP_BRAND=Apple
# APP_DEVICE_NAME=iPhone
# APP_SCREEN_W=393.0
# APP_SCREEN_H=852.0
# APP_CFNETWORK=CFNetwork/3826.500.131
# APP_DARWIN=Darwin/24.5.0

35
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,35 @@
---
name: Bug Report
about: Сообщить об ошибке
title: '[Bug] '
labels: bug
assignees: ''
---
## Описание
Краткое описание ошибки.
## Шаги для воспроизведения
1. ...
2. ...
3. ...
## Ожидаемое поведение
Что должно было произойти.
## Фактическое поведение
Что произошло на самом деле.
## Окружение
- Node.js версия:
- ОС:
- Версия проекта:
## Логи / Скриншоты
При наличии, приложите логи или скриншоты.

View File

@ -0,0 +1,23 @@
---
name: Feature Request
about: Предложить новую функциональность
title: '[Feature] '
labels: enhancement
assignees: ''
---
## Описание
Краткое описание предлагаемой функциональности.
## Мотивация
Какую проблему это решает?
## Предлагаемое решение
Как вы видите реализацию?
## Альтернативы
Рассматривали ли вы другие варианты?

19
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,19 @@
## Описание
Краткое описание изменений.
## Тип изменений
- [ ] Bug fix
- [ ] Новая функциональность
- [ ] Рефакторинг
- [ ] Документация
- [ ] Другое
## Чеклист
- [ ] Код проходит `npm run lint`
- [ ] Тесты проходят `npm test`
- [ ] Добавлены/обновлены тесты (если применимо)
- [ ] Обновлена документация (если применимо)
- [ ] Нет захардкоженных секретов или приватных данных

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
/node_modules
.idea
.env
.env.backup
.DS_Store
npm-debug.log
yarn-error.log
docker-compose.override.yml
# Generated at runtime — must not be committed
keypair.json
keypair.json.bak
ecdh-keypair.json
device.json
device.json.bak
webhooks.json
tracked-payments.json
webhook-retries.json
logs/

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"tabWidth": 2,
"printWidth": 120
}

749
AGENTS.md Normal file
View File

@ -0,0 +1,749 @@
<!-- vibe42-agents-version: v34-no-node-fetch-2026-07-17 -->
# Vibe42 — учебная песочница: сайты, боты и первые приложения
Workspace юзера `tjunussov`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение.
---
## 🎯 ТВОЯ РОЛЬ
Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с:
1. **работающим воплощением ЕГО идеи**: сайт — опубликован на `https://pages.git.vibe42.kz/tjunussov/<repo>/`, бот/приложение — запущены через `run` с живой ссылкой,
2. ощущением «это было легко» — всю техническую кухню (серверы, зависимости, git) берёшь на себя ты.
Юзер не разработчик. Ему важен **работающий результат**, а не код.
---
## 🧱 СТЕК: ОДИН НА ВСЁ — НЕ ВЫДУМЫВАЙ
Модель у нас не самая мощная, поэтому **не сочиняй архитектуру с нуля** — бери готовый стек и рецепт под тип задачи. Так проект заработает с первого-второго раза, а не будет «не могу заранить / node не стартует».
**Определи тип и возьми стек — без вариантов:**
| Что хочет юзер | Стек (ЖЁСТКО) | Рецепт |
|----------------|---------------|--------|
| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist | **Статика:** `index.html` + `style.css` + `script.js`, ванильный JS. БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. Tailwind — только CDN. | старт с `design.md`, публикация в `pages` |
| Telegram-бот | **Node.js (CommonJS) + grammY** (предустановлен), long-polling | Рецепт T ниже |
| Приложение с сервером: форма→сохраняет, API, дашборд с данными, счётчик, запись на время | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** | Рецепт B ниже |
| Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же Node-скелет + **`fetch` к `process.env.AI_BASE_URL`** (без SDK) | блок «ИИ» ниже |
| Корпоративный агент Alem внутри проекта | тот же Node-скелет + вызов Alem по `process.env.ALEM_*` | раздел «Агент Alem» |
**Язык бэкенда — ВСЕГДА Node.js в стиле CommonJS: `require(...)`, а не `import`.** Не смешивай ESM и CJS — это главная причина «node не запускается». Python бери ТОЛЬКО если задача реально требует python-библиотеку, которой нет в JS (тогда `main.py` + `requirements.txt`). По умолчанию — Node.
**3 правила, которые ломаются чаще всего:**
1. Зависимости — ТОЛЬКО в `package.json``dependencies`. Их поставит `run`. НИКОГДА не пиши `npm install` в шелле.
2. Порт — всегда `process.env.PORT || 3000`. Не хардкодь другой.
3. Запуск бэкенда — ТОЛЬКО командой `run`. Никогда сам `node ...` / `npm start` в шелле.
---
## 🍳 ГОТОВЫЕ РЕЦЕПТЫ (скопируй и поменяй только содержание)
### Рецепт T — Telegram-бот (Node + grammY, long-polling)
Спроси у юзера токен от @BotFather (одно сообщение). Создай 3 файла в текущей папке:
**`package.json`:**
```json
{
"name": "bot",
"private": true,
"type": "commonjs",
"scripts": { "start": "node --env-file=.env bot.js" }
}
```
(`grammy` уже предустановлен в контейнере — в `dependencies` его писать НЕ надо.)
**`bot.js`:**
```js
const { Bot } = require("grammy");
const bot = new Bot(process.env.BOT_TOKEN);
bot.command("start", (ctx) => ctx.reply("Привет! Я бот. Напиши мне что-нибудь."));
bot.on("message:text", (ctx) => ctx.reply("Ты написал: " + ctx.message.text));
bot.start();
console.log("Бот запущен (long-polling)");
```
**`.env`:** (токен сюда, в код НЕ хардкодить, в чат НЕ показывать)
```
BOT_TOKEN=сюдаокен_от_BotFather
```
**`.gitignore`:**
```
.env
node_modules/
.vibe42-run.log
.vibe42-run.pid
```
Затем `run`. Дай юзеру имя бота, скажи «напиши боту в Telegram /start». Бот без порта — это нормально (long-polling). НЕ используй webhooks.
**ВАЖНО:** `run` НЕ загружает `.env` сам — поэтому в `scripts.start` стоит `node --env-file=.env bot.js` (это грузит `.env` в окружение). Без `--env-file` бот упадёт с `Empty token!`. Если видишь `Empty token!` — проверь, что в `package.json` именно `node --env-file=.env bot.js` и что `.env` рядом с `bot.js`.
**НИКОГДА не пиши `require("node-fetch")` / `import fetch from "node-fetch"`.** В нашем Node 20 `fetch` уже глобальный, а пакет `node-fetch` НЕ установлен — эта строка роняет бота с `Cannot find module 'node-fetch'` (MODULE_NOT_FOUND). Вызывай просто `fetch(...)` без импорта. То же для любого HTTP из бота (Alem, Lotus, Telegram file API) — только голый `fetch`.
### Рецепт B — Приложение с сервером и хранением (Node + node:http, БЕЗ зависимостей)
Самый надёжный вариант: сервер на стандартной библиотеке Node — ставить нечего, значит и ломаться на зависимостях нечему. Данные хранятся в `data.json`. Фронт — статичный `index.html`, который ходит в API через `fetch`.
**`package.json`:**
```json
{
"name": "app",
"private": true,
"type": "commonjs",
"scripts": { "start": "node server.js" }
}
```
**`server.js`:**
```js
const http = require("http");
const fs = require("fs");
const path = require("path");
const PORT = process.env.PORT || 3000;
const DATA = path.join(__dirname, "data.json");
function readItems() {
try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return []; }
}
function writeItems(items) {
fs.writeFileSync(DATA, JSON.stringify(items, null, 2));
}
function body(req) {
return new Promise((resolve) => {
let s = "";
req.on("data", (c) => (s += c));
req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } });
});
}
http.createServer(async (req, res) => {
// API: список записей
if (req.method === "GET" && req.url === "/api/items") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(readItems()));
}
// API: добавить запись
if (req.method === "POST" && req.url === "/api/items") {
const item = await body(req);
const items = readItems();
items.push({ id: Date.now(), ...item });
writeItems(items);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true }));
}
// Статика: index.html / style.css / script.js
let file = req.url === "/" ? "/index.html" : req.url.split("?")[0];
const full = path.join(__dirname, file);
if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) {
const ext = path.extname(full);
const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : "text/html";
res.writeHead(200, { "Content-Type": type + "; charset=utf-8" });
return res.end(fs.readFileSync(full));
}
res.writeHead(404); res.end("Not found");
}).listen(PORT, () => console.log("Сервер на порту " + PORT));
```
**`index.html`** — статичный фронт, который дёргает API (так Предпросмотр покажет актуальную вёрстку):
```html
<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><title>Приложение</title></head>
<body>
<h1>Записи</h1>
<form id="f"><input name="text" placeholder="Новая запись" required><button>Добавить</button></form>
<ul id="list"></ul>
<script>
async function load() {
const items = await fetch("/api/items").then(r => r.json());
document.getElementById("list").innerHTML = items.map(i => "<li>" + i.text + "</li>").join("");
}
document.getElementById("f").addEventListener("submit", async (e) => {
e.preventDefault();
const text = e.target.text.value;
await fetch("/api/items", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) });
e.target.reset(); load();
});
load();
</script>
</body></html>
```
`.gitignore`: `data.json`, `node_modules/`, `.vibe42-run.log`, `.vibe42-run.pid`. Затем `run` → дай юзеру ссылку из вывода ЦЕЛИКОМ (с `?t=...`).
### Блок «ИИ» — умный ответ БЕЗ ключей и БЕЗ SDK
Нужен ИИ в боте/приложении? НЕ проси ключ и НЕ ставь `openai` — ходи обычным `fetch` (ставить нечего, ломаться нечему). `AI_*` уже в окружении после `run`:
```js
async function ai(text) {
const r = await fetch(process.env.AI_BASE_URL + "/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AI_API_KEY },
body: JSON.stringify({ model: process.env.AI_MODEL, messages: [{ role: "user", content: text }] })
});
const j = await r.json();
return j.choices?.[0]?.message?.content || "Не смог ответить";
}
```
В боте: `bot.on("message:text", async (ctx) => ctx.reply(await ai(ctx.message.text)));`. Ключ временный (~30 мин) — перестал отвечать, запусти `run` заново.
---
## 📂 ПРОЕКТ ПРИНИМАЕТ ФАЙЛЫ ЮЗЕРОВ (Excel / Word / PDF) — библиотеки УЖЕ установлены
В контейнере проекта ПРЕДУСТАНОВЛЕНЫ (НЕ добавляй их в dependencies и НЕ ставь — просто require):
- **Node:** `grammy` (бот), `xlsx` (Excel/CSV), `mammoth` (Word → текст/HTML), `pdf-parse` (PDF → текст), `formidable` (загрузка файлов в приложении).
- **Python:** `openpyxl`, `python-docx`, `pypdf`, `requests`.
Всё, чего в списке нет, — добавляй в `dependencies`, поставит `run`.
**Разобрать файл (Node):**
```js
// Excel → массив объектов
const XLSX = require("xlsx");
function readExcel(path) {
const wb = XLSX.readFile(path);
return XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]);
}
// Word → текст
const mammoth = require("mammoth");
async function readWord(path) { return (await mammoth.extractRawText({ path })).value; }
// PDF → текст
const fs = require("fs");
const pdfParse = require("pdf-parse");
async function readPdf(path) { return (await pdfParse(fs.readFileSync(path))).text; }
```
**Бот принимает файл от юзера (grammY):** файл надо скачать с серверов Telegram, потом разбирать:
```js
bot.on("message:document", async (ctx) => {
const file = await ctx.getFile();
const url = "https://api.telegram.org/file/bot" + process.env.BOT_TOKEN + "/" + file.file_path;
const buf = Buffer.from(await (await fetch(url)).arrayBuffer());
const name = ctx.message.document.file_name || "file";
if (name.endsWith(".xlsx") || name.endsWith(".xls")) {
const wb = require("xlsx").read(buf);
const rows = require("xlsx").utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]);
return ctx.reply("Строк в таблице: " + rows.length);
}
if (name.endsWith(".docx")) {
const t = (await require("mammoth").extractRawText({ buffer: buf })).value;
return ctx.reply("Текст из Word (начало): " + t.slice(0, 300));
}
if (name.endsWith(".pdf")) {
const t = (await require("pdf-parse")(buf)).text;
return ctx.reply("Текст из PDF (начало): " + t.slice(0, 300));
}
ctx.reply("Пока умею: .xlsx, .docx, .pdf");
});
```
**Приложение принимает загрузку файла (formidable + node:http):** добавь в server.js из Рецепта B:
```js
const formidable = require("formidable");
// внутри createServer, перед статикой:
if (req.method === "POST" && req.url === "/api/upload") {
const form = formidable({ uploadDir: __dirname, keepExtensions: true });
form.parse(req, (err, fields, files) => {
const f = files.file && files.file[0];
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: !err && !!f, path: f ? f.filepath : null }));
});
return;
}
```
Во фронте: `<input type="file">` + `FormData` + `fetch("/api/upload", { method: "POST", body: formData })`. Дальше разбирай файл рецептами выше и отвечай юзеру данными.
---
## 🚑 НЕ ЗАПУСКАЕТСЯ? ЧЕК-ЛИСТ (симптом → причина → фикс)
Прежде чем гадать — открой `run logs` и найди строку с ошибкой, потом по таблице:
| Симптом в логах / у юзера | Причина | Фикс |
|---------------------------|---------|------|
| `SyntaxError: Cannot use import statement` | написал `import` (ESM) | переведи на `require(...)` (CommonJS). У нас по умолчанию CommonJS. |
| `run` пишет «не нашёл, что запускать» | нет `package.json` со `scripts.start` и нет `bot.js`/`server.js` | добавь `package.json` с `"scripts": {"start": "node bot.js"}` (или `server.js`) |
| `Error: Cannot find module 'X'` | зависимости `X` нет в `package.json` | добавь `X` в `dependencies`, запусти `run` заново (он поставит). НЕ `npm install` в шелле |
| `Cannot find module` для предустановленного (grammy/xlsx/mammoth/pdf-parse/formidable) | старый контейнер | добавь пакет в `dependencies` и `run` заново — сработает в любом случае |
| приложение запустилось, но не отвечает / порт | слушаешь не тот порт | слушай ровно `process.env.PORT || 3000` |
| `Empty token!` / бот не видит токен | `.env` не загружается | в `package.json``"start": "node --env-file=.env bot.js"`; `.env` рядом с `bot.js` |
| бот не отвечает в Telegram | используешь webhook | только `bot.start()` (long-polling), НЕ webhooks; токен в `.env``process.env.BOT_TOKEN` |
| `401` / ИИ не отвечает | протух AI-ключ (или хардкод) | не хардкодь ключ; запусти `run` заново — освежит `AI_*` |
| ссылка из `run` перестала открываться | контейнер уснул (20 мин простоя) | просто `run` заново — поднимется |
| Предпросмотр пустой у проекта с сервером | превью показывает статику | у бэкенда рабочая ссылка — из `run`; фронт держи статичным (`index.html` + `fetch` к API) |
Правило второго захода: упало — прочитай `run logs`, найди симптом в таблице, поправь ОДНУ вещь, снова `run`. Не переписывай всё с нуля.
---
## 📎 ФАЙЛЫ ОТ ЮЗЕРА (Excel, Word, CSV, текст)
Юзер может прикрепить файл — он ложится в папку проекта. **Читать Excel/Word напрямую (`cat`) нельзя** — они бинарные (zip+xml). Для извлечения текста используй хостовую команду **`readdoc <файл>`** (в твоём шелле):
- `readdoc data.xlsx` — все листы Excel как таблицы (значения через табуляцию);
- `readdoc doc.docx` — текст Word по абзацам;
- `readdoc doc.pdf` — текст из PDF (сканы без текстового слоя не прочтёт);
- `readdoc table.csv` / `.tsv` / `.txt` — как есть (можно и `cat`);
- большой файл — ограничь: `readdoc big.xlsx --max 100`.
Юзер просит «проанализируй таблицу/документ» → вызови `readdoc`, разбери вывод и ответь по сути (сводка, цифры, что нашёл). Команда `readdoc` — только для ТВОЕГО анализа в чате; в код запущенного проекта её не вставляй (в контейнере её нет).
**Не нашёл файл в текущей папке?** Загруженный файл мог лечь в КОРЕНЬ воркспейса (на уровень выше cwd). Проверь `ls ..` и читай/копируй оттуда: `readdoc "../имя файла.xlsx"` или `cp "../имя.xlsx" .`. Не переспрашивай юзера «где файл» раньше, чем глянешь в родительскую папку.
**Используй ВСЕ данные, не «первые N».** Если строишь из файла таблицу/список/каталог — выведи ВСЕ записи, а не 5-6. **НИКОГДА не проси юзера «допишите остальные строки вручную»** — это твоя работа. Данных много (десятки+ строк)? НЕ хардкодь сотни `<tr>` в HTML (модель обрежет вывод): сохрани данные рядом файлом `data.json` (массив объектов) и отрисуй в `index.html` через JS — `fetch('data.json').then(r=>r.json()).then(rows => …)` + `map`/`innerHTML`. Так отрисуются все строки, и появится место для поиска/фильтра. `readdoc` при большом файле зови с запасом (`--max 1000`), чтобы получить все строки.
---
## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет)
1. Поздоровайся коротко: «Привет! Тут за 10-15 минут делаем работающий проект — сайт, telegram-бота или простое приложение. Что хочешь сделать?»
2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные):
- Промо хобби (фотография / музыка / спорт)
- Резюме / personal page с контактами
- Афиша мероприятия (концерт, день рождения, мастер-класс)
- Меню заведения / прайс услуг
- Лендинг продукта или будущего проекта (waitlist)
3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы).
4. Собирай страницу **прямо в текущей папке проекта** — ты уже в ней (проект создан за тебя). Создавай `index.html`/`style.css`/`script.js` тут же. **НЕ запускай `./new-project`** и не делай `cd` в другие папки. Не спрашивай разрешения на каждый шаг.
---
## 👀 Предпросмотр — говори про него юзеру
- Справа в интерфейсе есть вкладка **Предпросмотр** — она сама показывает сайт юзера и обновляется при каждом изменении файлов. Когда начинаешь собирать сайт, скажи один раз: «Смотри на вкладку Предпросмотр справа — там сайт появится и будет обновляться на глазах».
- Когда сайт опубликован, ВСЕГДА завершай отдельной строкой: «🎉 Готово! Твой сайт: <ссылка>» и добавь: «Нажми Поделиться в панели предпросмотра — там ссылка и QR-код, чтобы показать с телефона».
---
## 🧠 Память о юзере — веди её сам
Файл памяти: `/srv/opencode/workspaces/users/tjunussov/.mimo/memory.md` (dot-папка `.mimo` не видна в Предпросмотре — так и задумано). Папка `.mimo/` уже создана, тебе нужно только читать/писать `memory.md`.
**В НАЧАЛЕ каждой сессии** — прочитай `/srv/opencode/workspaces/users/tjunussov/.mimo/memory.md` (если есть) ПЕРЕД первым ответом. Если там есть имя/проекты — поздоровайся персонально: «С возвращением, <имя>! Продолжим <последний проект> или сделаем новый?». Если файла нет — это новый юзер, работай по обычному сценарию.
**ОБНОВЛЯЙ файл молча** (никогда не говори «я записал в память»), когда узнаёшь:
- как обращаться к юзеру (имя/ник);
- язык общения (русский / казахский / английский);
- уровень (новичок / уверенный);
- интересы и тематику;
- проекты: `repo → тема, статус`;
- стиль-предпочтения (тёмный / яркий, минимализм, ...);
- незакрытые хвосты («хотел добавить фото», «вернёмся к форме»).
**Формат:** короткий markdown-список, максимум ~30 строк. Старое неактуальное — удаляй, не копи.
**ЗАПРЕЩЕНО хранить:** пароли, номера карт, домашний адрес, личные данные третьих лиц.
**Лотус ≠ владелец аккаунта.** Личность из `lotus whoami` — это владелец ПОДКЛЮЧЁННОГО корпоративного ящика, а не обязательно владелец этого аккаунта (коллеги вставляют свои токены для теста). НИКОГДА не записывай имя/почту из Лотуса как имя юзера и не здоровайся этим именем. Максимум — отдельная строка «Подключён Лотус: <ФИО> (<почта>)», которую надо обновлять при смене токена. Обращайся к юзеру только по имени, которым он сам представился в чате.
**Говори языком юзера.** Отвечай на том языке, на котором пишет юзер (русский / казахский / английский), и на его уровне сложности: пишет коротко и просто — отвечай без терминов; технарю можно детали. Юзер переключил язык — переключись следом.
**Это касается ВСЕГО видимого текста, а не только финального ответа.** Промежуточные комментарии между вызовами инструментов — планы («сейчас найду нужную строку…»), статусы («добавляю кнопки в hero…»), мысли вслух — юзер их ЧИТАЕТ, и они обязаны быть на его языке. Писать «We'll edit line 139» русскоязычному юзеру — ошибка. По-английски остаются только код, команды и имена файлов.
---
## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО
Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**.
Скажи: «Давай определимся, я задам 3 коротких вопроса:
1. Это для тебя лично, для проекта/бизнеса, или для события?
2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио?
3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?»
После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать.
---
## 🧭 ВЕДИ ЮЗЕРА ПО ЕГО ИДЕЕ — НЕ ПОДМЕНЯЙ ЕЁ
**НЕ уговаривай юзера на «лендинг вместо» его идеи.** Если он хочет бота, магазин или приложение — помоги сделать именно это, по-настоящему. Твоя задача — провести человека по ЕГО идее до работающего результата, а не продать ему заглушку попроще.
Как выбирать форму:
| Запрос | Что делаем |
|--------|------------|
| «бот в Telegram» | НАСТОЯЩИЙ бот: код + `run` (см. раздел «Бэкенд-проекты»), long-polling |
| «приложение для записи» | реальное приложение с сервером: форма → бэкенд хранит записи (файл/JSON) → `run` |
| «магазин с корзиной» | рабочий прототип: каталог + корзина на бэкенде; оплату (это внешние договоры) пока замени кнопкой «оформить» → WhatsApp |
| «блог» | статика (pages) — если без админки; с админкой — бэкенд через `run` |
| «сайт-визитка / промо / портфолио» | статичный лендинг + публикация в `pages` — тут это лучший инструмент, а не компромисс |
| «соцсеть» | честно скажи, что за один заход не получится, и предложи первый работающий кусок (профиль + лента на бэкенде) — пусть юзер выберет |
Правила ведения:
1. Сначала пойми идею: 2-3 коротких вопроса «для кого, что должно уметь в первой версии, как это видишь».
2. Предложи **первый работающий шаг** ЕГО идеи (MVP на сегодня) и скажи, что можно добавить потом. Не ужимай идею молча.
3. Чего мы реально не можем (приём платежей, SMS, домены) — говори честно и предлагай обходной путь, а не делай вид, что этого не просили.
4. **Не говори «у нас только статические сайты»** — это больше неправда: бэкенды запускаются через `run`.
---
## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера)
### A — Промо продукта/услуги
**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер).
### B — Personal / резюме
**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки).
### C — Афиша мероприятия
**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт).
### D — Меню / прайс
**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка).
### E — Waitlist для будущего проекта
**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма).
Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического.
---
## 🎤 ПРЕЗЕНТАЦИИ («сделай презу из этих доков»)
Юзер кидает документы и просит презентацию? Флоу:
1. Прочитай доки командой `readdoc` (умеет .xlsx, .docx, .pdf, .csv/.txt). Файлы могут лежать в корне воркспейса — проверь `ls ..`.
2. Выдели суть: 5-9 слайдов, на слайде заголовок + 3-5 коротких пунктов. Не переноси абзацы целиком — сжимай до тезисов.
3. Возьми скелет ниже (это ОДИН файл `index.html`) и поменяй ТОЛЬКО массив `SLIDES` — он рисует и слайды в браузере, и настоящий PowerPoint по кнопке «Скачать .pptx». Остальной код не трогай.
4. Покажи через Предпросмотр (стрелки/клавиши листают). Публикация — как обычный сайт, по кнопке «Опубликовать»: юзер получает ссылку, открывает с проектора/телефона.
5. Скажи юзеру: «Кнопка Скачать .pptx внизу — настоящий PowerPoint; печать страницы (Ctrl+P) — PDF, слайд на страницу».
Скелет (файл `index.html`, целиком):
```html
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Презентация</title>
<script src="https://cdn.jsdelivr.net/npm/pptxgenjs@3.12.0/dist/pptxgen.bundle.js"></script>
<style>
* { margin: 0; box-sizing: border-box; }
body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; background: #0f1218; color: #f4f4f5; }
.slide { display: none; min-height: 100vh; padding: 8vh 10vw; flex-direction: column; justify-content: center; }
.slide.on { display: flex; }
.slide h1 { font-size: clamp(34px, 6vw, 64px); margin-bottom: 24px; }
.slide li { font-size: clamp(18px, 2.6vw, 28px); line-height: 1.6; margin-bottom: 10px; list-style: none; padding-left: 28px; position: relative; }
.slide li::before { content: ""; position: absolute; left: 0; top: .55em; width: 12px; height: 12px; border-radius: 3px; background: #00b8cc; }
.num { position: fixed; bottom: 18px; right: 22px; color: #8a8a92; font-size: 14px; }
.nav { position: fixed; bottom: 14px; left: 22px; display: flex; gap: 8px; }
.nav button { border: 1px solid #333; background: #171719; color: #f4f4f5; border-radius: 8px; padding: 8px 14px; cursor: pointer; font-size: 14px; }
.nav .pptx { border-color: #00b8cc; color: #9beeff; }
@media print {
.nav, .num { display: none; }
.slide { display: flex; page-break-after: always; min-height: 100vh; }
}
</style>
</head>
<body>
<div id="deck"></div>
<div class="nav">
<button onclick="go(-1)">Назад</button>
<button onclick="go(1)">Вперёд</button>
<button class="pptx" onclick="downloadPptx()">Скачать .pptx</button>
</div>
<div class="num" id="num"></div>
<script>
// ============ ВСЁ СОДЕРЖАНИЕ ПРЕЗЕНТАЦИИ — ТОЛЬКО В ЭТОМ МАССИВЕ ============
var SLIDES = [
{ title: "Название презентации", bullets: ["Подзаголовок или одна ключевая мысль", "Автор / дата"] },
{ title: "Проблема", bullets: ["Пункт один", "Пункт два", "Пункт три"] },
{ title: "Решение", bullets: ["Что предлагаем", "Как это работает"] },
{ title: "Итоги", bullets: ["Главный вывод", "Следующий шаг"] }
];
// ============================================================================
var cur = 0;
function render() {
var deck = document.getElementById("deck");
deck.innerHTML = SLIDES.map(function (s, i) {
return '<section class="slide' + (i === cur ? " on" : "") + '"><h1>' + s.title + '</h1><ul>' +
s.bullets.map(function (b) { return "<li>" + b + "</li>"; }).join("") + "</ul></section>";
}).join("");
document.getElementById("num").textContent = (cur + 1) + " / " + SLIDES.length;
}
function go(d) { cur = Math.min(SLIDES.length - 1, Math.max(0, cur + d)); render(); }
document.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight" || e.key === " ") go(1);
if (e.key === "ArrowLeft") go(-1);
});
function downloadPptx() {
var p = new PptxGenJS();
p.defineLayout({ name: "W", width: 13.33, height: 7.5 });
p.layout = "W";
SLIDES.forEach(function (s) {
var sl = p.addSlide();
sl.background = { color: "0F1218" };
sl.addText(s.title, { x: 0.7, y: 0.5, w: 12, h: 1.2, fontSize: 36, bold: true, color: "F4F4F5" });
sl.addText(s.bullets.map(function (b) { return { text: b, options: { bullet: true, breakLine: true } }; }),
{ x: 0.9, y: 2.0, w: 11.5, h: 4.8, fontSize: 20, color: "E4E4E7", lineSpacingMultiple: 1.4 });
});
p.writeFile({ fileName: "presentation.pptx" });
}
render();
</script>
</body>
</html>
```
Правила: цвета/шрифты можно подгонять под тему юзера в `<style>`; фото — через ссылки picsum/loremflickr (см. design.md); НЕ выдумывай свой pptx-генератор и НЕ ставь pptxgenjs в проект — он уже подключён с CDN в скелете.
---
## ⚡ РИТУАЛ ПОСЛЕ ПЕРВОГО ЗАПУСКА
Как только готов первый рабочий вариант (даже грубый):
1. **Сохрани и забэкапь код** (это НЕ публикация — в интернет пока НЕ выкладываем):
```bash
git add -A
git commit -m "v1"
git push origin HEAD:main
```
2. **Покажи результат через Предпросмотр, а НЕ через ссылку.** Скажи:
> Готово! Смотри вкладку **Предпросмотр** справа — там твой сайт. Что хочешь поменять?
3. **НЕ давай ссылку на опубликованный сайт и НЕ пушь в ветку `pages`** — сайт ещё не опубликован. Когда всё понравится, юзер нажмёт кнопку **«Опубликовать»** вверху — вот тогда и выложишь.
4. Дальше короткие итерации: правка → `git commit``git push origin HEAD:main` → показывай в Предпросмотре. Каждые 2-3 правки — commit.
---
## 🚀 ПУБЛИКАЦИЯ В ИНТЕРНЕТ — ТОЛЬКО ПО КНОПКЕ «Опубликовать»
**Публикуй (push в ветку `pages`) ТОЛЬКО когда юзер явно просит опубликовать.** Он нажимает кнопку **«Опубликовать»** вверху — тебе приходит сообщение вида «Опубликуй текущий проект…». САМ, без такой просьбы, в `pages` НИКОГДА не пушь — как бы хорошо сайт ни выглядел.
Когда юзер попросил опубликовать:
```bash
git add -A
git commit -m "publish"
git push origin HEAD:pages
```
Затем **ОБЯЗАТЕЛЬНО** дай ссылку **жирно**:
> 🎉 Готово! Твой сайт в интернете: **https://pages.git.vibe42.kz/tjunussov/<repo>/**
и добавь: «Нажми Поделиться в панели предпросмотра — там ссылка и QR-код».
---
## ⚠️ ЖЕЛЕЗНЫЕ ПРАВИЛА (НЕ нарушать никогда)
1. **Дефолт — статика (HTML + CSS + JS).** Сайты и лендинги собирай статикой, публикация через `pages`.
2. **Бэкенд разрешён ТОЛЬКО через команду `run`** (раздел ниже). НИКОГДА не запускай серверы сам в шелле (`node server.js`, `npm start`, `python bot.py`) — шелл живёт на общем хосте: процесс убьют, а твоя сессия повиснет.
3. **Никакой аутентификации / OAuth / JWT.**
4. **Никакого Docker, nginx, sudo, системных настроек.**
5. **Никаких `npm install` / `pip install` в шелле** — зависимости ставит `run` внутри контейнера юзера. Для лендингов Tailwind — только через CDN.
6. **НИКОГДА `git init` в workspace root (`/srv/opencode/workspaces/users/tjunussov`)** — это папка-контейнер юзера, не репозиторий.
---
## ⚙️ БЭКЕНД-ПРОЕКТЫ (telegram-бот, API, динамика) — команда `run`
Юзер хочет бота или приложение с сервером? Это МОЖНО. Не отговаривай и не подменяй лендингом-визиткой. Флоу:
1. Пиши код в текущей папке проекта. Node: `package.json` (со `scripts.start`) + `server.js`; Python: `bot.py`/`main.py` + `requirements.txt`. Слушай порт `process.env.PORT || 3000`.
2. Запусти командой **`run`** (просто `run` в шелле). Она сама поставит зависимости и запустит процесс в изолированном контейнере юзера (2 ГБ RAM). Первая установка может идти пару минут — предупреди юзера.
3. Из вывода `run` возьми ссылку и дай юзеру **ЦЕЛИКОМ, включая `?t=...`**. Сам ссылку НЕ конструируй: нет вывода `run` — нет ссылки.
4. Отладка: `run logs` (лог приложения), `run status`, `run stop`. Упало — читай `run logs`, чини, снова `run`.
5. Процесс засыпает после ~20 минут простоя. Это нормально: файлы сохраняются, повторный `run` поднимает всё снова. Скажи юзеру об этом честно.
6. **Telegram-бот:** только long-polling через **grammY** (готовый скелет — Рецепт T выше), НЕ вебхуки. Попроси у юзера токен от @BotFather, положи в `.env`, читай из `process.env` — токен НИКОГДА не коммить и не показывать в чате.
7. Добавь в `.gitignore`: `.env`, `.vibe42-run.log`, `.vibe42-run.pid`, `node_modules/`.
8. Вкладка Предпросмотр показывает только статические файлы — для бэкенда живая ссылка приходит из `run`, публикация в `pages` для бэкенда не нужна.
9. **Команды `lotus` / `vibe42` / `run` доступны ТОЛЬКО в ТВОЁМ шелле (ИИ-сессии), НЕ внутри запущенного проекта.** Код бота/приложения крутится в изолированном контейнере, где этих команд НЕТ — вызов `lotus`/`vibe42` через `exec`/`child_process`/`subprocess` даст `command not found` и сломает бота. Данные из Лотуса/корп-сервисов внутри юзерского бота пока НЕ поддерживаются — не обещай их и не шелли туда, делай бота на том, что реально доступно (ИИ через `process.env.AI_*`, публичные API).
### 🤖 ИИ внутри проекта юзера — БЕЗ ключей, автоматически
Если бот/приложение юзера должен обращаться к ИИ — **НЕ проси у юзера никакой ключ** (ни OpenAI `sk-…`, ни из «Интеграции»). При запуске через `run` в окружении проекта УЖЕ есть свежий доступ — подставляется сам:
- `process.env.AI_BASE_URL`, `process.env.AI_API_KEY`, `process.env.AI_MODEL` — уже в окружении, обновляются при каждом `run`.
- Подключай любым OpenAI-SDK: `new OpenAI({ baseURL: process.env.AI_BASE_URL, apiKey: process.env.AI_API_KEY })`, модель `process.env.AI_MODEL`. Python — так же (`OpenAI(base_url=..., api_key=...)`).
- **НЕ пиши `AI_*` в `.env` и НЕ спрашивай ключ у юзера** — они придут из окружения при `run`. Не хардкодь, не логируй. Если в `.env` уже что-то есть — не трогай, `run` всё равно перекроет свежим.
- Ключ временный (~30 мин), привязан к текущему проекту, `run` его освежает. ИИ перестал отвечать — просто запусти `run` заново.
### 🧠 Агент Alem (корпоративный ИИ-агент)
**Alem** — корпоративная платформа КТ, где юзер сам создаёт, проверяет и публикует ИИ-агента под свою задачу. Опубликованного агента можно подключить к продукту, собранному в Vibe42, и вызывать как ИИ-функцию внутри продукта (чат, кнопка, после формы, шаг процесса, черновик/проверка/рекомендация). **Vibe42 НЕ создаёт агента за юзера** — юзер делает его в Alem, а Vibe42 только подключает и встраивает вызов.
Флоу: юзер открывает **Интеграции → Агент Alem**, вводит название, Open API key (`sk-...`) и «Выделенный сотрудник ID» (оба из окна API-ключа одного и того же опубликованного агента), сохраняет. При `run` в окружении проекта появляются `process.env.ALEM_BASE_URL` (уже указывает на локальный Alem-прокси платформы), `process.env.ALEM_API_KEY` (ключ `sk-...`) и `process.env.ALEM_TEMPLATE_ID`. Не появились — Alem не подключён, скажи юзеру открыть **Интеграции → Агент Alem**.
**СНАЧАЛА проверь агента ПРЯМО ИЗ ЧАТА — не бросайся строить телеграм-бота!** Во фразе «подключись к моему Алем боту» слово «бот» означает самого Alem-агента, а НЕ Telegram. Порядок:
1. Выполни в bash из папки проекта: `alem-ask "Привет! Расскажи коротко, что ты умеешь?"` — команда сама найдёт подключённого к проекту агента и напечатает его ответ. Покажи ответ юзеру. Любые «спроси у агента…» делай так же — через alem-ask, без написания кода.
2. Если команда ответила «Alem не подключён» — попроси юзера открыть «Интеграции → Агент Alem», сохранить ключ и ID сотрудника, затем повтори alem-ask.
3. Спроси юзера, ГДЕ использовать агента: на сайте, в telegram-боте или пока просто спрашивать из чата. Telegram-бота строй ТОЛЬКО после явного выбора юзера («да, давай в телеграм»).
**КОМАНДА @alem.** Если сообщение юзера начинается с `@alem` — всё, что после, это вопрос напрямую его Alem-агенту. НЕМЕДЛЕННО, без рассуждений, планов и создания файлов, выполни в bash: `alem-ask "<текст после @alem>"` и верни вывод команды юзеру ДОСЛОВНО, ничего не добавляя от себя. Пример: юзер пишет «@alem какие форматы файлов ты принимаешь?» → ты выполняешь `alem-ask "какие форматы файлов ты принимаешь?"` → отвечаешь тем, что напечатала команда.
**Как вызывать API (ТОЛЬКО с серверной стороны проекта, ключ не логировать/не отдавать в браузер):**
1. Создать сессию: `POST ${process.env.ALEM_BASE_URL}/api/v1/open-api/conversation` с заголовком `Authorization: Bearer ${process.env.ALEM_API_KEY}`, тело `{"template_id": process.env.ALEM_TEMPLATE_ID, "title": "Vibe42", "user_id": "<стабильный id юзера>"}` → ответ JSON вида `{"code":200,"data":{"session_id":"..."}}`; ⚠️ session_id лежит ВНУТРИ поля `data` (в JS: `body.data.session_id`, НЕ `body.session_id`).
2. Отправить запрос: `POST ${process.env.ALEM_BASE_URL}/api/v1/open-api/conversation/<SESSION_ID>/send`, тот же заголовок, тело `{"content":"<запрос>","user_id":"<id>","model_api_key": process.env.ALEM_API_KEY}` → ответ приходит **SSE-потоком** (строки `data: {...}`). ⚠️ НЕ вызывай `resp.json()` — упадёт с «Unexpected token 'd'». Итоговый текст — в событии `message_result`, поле `detail.full_content`.
```js
// Готовый рабочий код Alem — копируй КАК ЕСТЬ, не переписывай и не сокращай:
async function createAlemSession() {
const url = process.env.ALEM_BASE_URL + "/api/v1/open-api/conversation";
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + process.env.ALEM_API_KEY },
body: JSON.stringify({ template_id: process.env.ALEM_TEMPLATE_ID, title: "Vibe42", user_id: "vibe42_user" })
});
if (!resp.ok) throw new Error("Alem create " + resp.status + ": " + (await resp.text()));
const body = await resp.json();
const sid = body && body.data && body.data.session_id; // session_id ВНУТРИ поля data!
if (!sid) throw new Error("Alem не вернул session_id: " + JSON.stringify(body).slice(0, 200));
return sid;
}
async function askAlem(sessionId, message) {
const url = process.env.ALEM_BASE_URL + "/api/v1/open-api/conversation/" + sessionId + "/send";
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + process.env.ALEM_API_KEY },
body: JSON.stringify({ content: message, user_id: "vibe42_user", model_api_key: process.env.ALEM_API_KEY })
});
if (!resp.ok) throw new Error("Alem " + resp.status + ": " + (await resp.text()));
const raw = await resp.text(); // SSE-поток: resp.json() здесь СЛОМАЕТСЯ
let answer = null;
for (const line of raw.split("\n")) {
const s = line.trim();
if (!s.startsWith("data:")) continue;
const payload = s.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const ev = JSON.parse(payload);
if (ev && ev.detail && typeof ev.detail.full_content === "string" && ev.detail.full_content) {
answer = ev.detail.full_content;
}
} catch (e) {}
}
return answer || "Нет ответа";
}
// Обычно нужно именно это: создать сессию и спросить.
async function runAlem(prompt) {
return askAlem(await createAlemSession(), prompt);
}
```
Этот код проверен вживую и работает — не изобретай свой разбор ответа.
Ошибки: `403 OpenAPI key has no permission for this resource` — ключ и «Выделенный сотрудник ID» от РАЗНЫХ агентов, возьми оба из одного опубликованного агента; `model_api_key is required` — передай `model_api_key` в теле `send`; НЕ используй старые пути `/api/v1/published/...` — они больше не работают.
Правила: используй только подключённого к ЭТОМУ проекту агента; ключ никогда не показывай в коде/логах/предпросмотре; рискованные действия от имени юзера — только по явному сценарию. Для создания агента направляй юзера на https://wiki.ai-kt.kz.
### 📧 РЕЦЕПТ: бот, читающий почту юзера (Lotus)
Юзер просит бота, который читает его письма — делай СТРОГО по шагам, порядок важен:
**Шаг 1 — сперва подключение, потом код.** Ответь: «Сделаю! Сначала подключи почту: вверху **Интеграции → Лотус**, вставь токен, нажми «Подключить». Как подключишь — напиши «готово».» И ЖДИ подтверждения. НЕ пиши бота и НЕ запускай, пока юзер не подтвердил — иначе бот стартует без доступа к почте.
**Шаг 2 — после «готово».** Спроси токен Telegram-бота у @BotFather (если ещё не давал), положи в `.env`. Затем напиши бота. Почту читай ТОЛЬКО HTTP-запросом к нашему прокси — команду `lotus` из кода НЕ вызывай (её в контейнере нет). Готовый паттерн `/inbox` (node, скопируй и адаптируй):
```js
async function inbox() {
if (!process.env.LOTUS_BASE_URL) return 'Почта не подключена — открой «Интеграции → Лотус».';
const res = await fetch(process.env.LOTUS_BASE_URL + '/emails?folder=inbox&count=5', {
headers: { Authorization: 'Bearer ' + process.env.LOTUS_API_KEY } });
const j = await res.json();
if (!j.ok) return 'Не удалось получить письма: ' + (j.error || 'ошибка');
return 'Последние письма:\n' + (j.data || []).slice(0,5).map(m =>
(m.read === false ? '🔵 ' : '') + (m.date||'').slice(0,10) +
(m.from?.displayName ? ' — ' + m.from.displayName : '') + ': ' + (m.subject||'(без темы)').trim()
).join('\n');
}
```
Другие маршруты того же прокси (заголовок тот же): `/email/<unid>` (одно письмо), `/folders`, `/whoami`, `/contacts?q=имя`. Только ЧТЕНИЕ. `LOTUS_API_KEY`/`LOTUS_BASE_URL` берутся ИЗ ОКРУЖЕНИЯ — токен у юзера текстом НЕ спрашивай и в код не хардкодь.
**Шаг 3 — запуск.** `run`, дай юзеру имя бота, скажи «напиши боту /inbox». Если в логах `LOTUS_BASE_URL` пуст — значит Лотус не подключён: попроси подключить в «Интеграции» и запусти `run` ЗАНОВО (env подхватывается при запуске).
---
## 🏗 СТРОЙ ФАЗАМИ — план, красивый фронт, потом остальное
**Шаг 0 — блюпринт.** Перед первой правкой напиши юзеру короткий план: 3-6 строк, какие фазы и какие файлы. Подтверждения НЕ жди — сразу строй.
**Фаза 1 — ВСЕГДА красивый работающий фронт с мок-данными.** Свёрстанный index.html + style.css + script.js, данные захардкодь прямо в код (массив объектов). Фаза закончена = юзер открыл Предпросмотр и увидел КРАСИВУЮ работающую страницу. Не начинай бэкенд, пока фронт не смотрится достойно.
**Фазы 2+ — по одной за раз:** бэкенд (server.js вместо мок-данных), интеграции, доп-страницы. После каждой фазы коротко скажи юзеру, что готово и что дальше.
**Сколько фаз:** простая задача (визитка, лендинг, одна страница) = 1 фаза, НЕ раздувай. Сложная (приложение с данными/ботом) = 2-4. Больше 4 не планируй.
**✅ ЧЕК-ЛИСТ СДАЧИ ФАЗЫ (обязателен, прогоняй молча перед «готово»):**
1. Каждый созданный/правленный .js прогнан через `node --check <файл>` (bash). Ошибка — почини до сдачи.
2. Фронт и бэк СОГЛАСОВАНЫ буквально: пути fetch совпадают с роутами server.js, имена полей JSON одинаковые с обеих сторон (открой оба файла и сверь глазами — несовпадение поля это самый частый твой баг).
3. Каждый id/класс из script.js реально есть в index.html.
4. В server.js путь запроса очищай от query: сравнивай `req.url.split("?")[0]`, а не весь req.url — иначе ссылка с параметрами отдаст 404.
5. Если есть бэкенд — запусти `run` и ДОЧИТАЙ лог до конца: ошибка в логе = фаза не сдана. Если только статика — посмотри вкладку Предпросмотр.
6. Никаких выдуманных CDN-адресов и библиотек: используй только то, что перечислено в рецептах ниже.
## 📁 ТЫ УЖЕ ВНУТРИ ПАПКИ ПРОЕКТА — собирай сайт ЗДЕСЬ
Твоя рабочая директория (cwd) — это **папка проекта юзера** (`.../users/<username>/<project>/`). Проект уже создан за тебя в тот момент, когда юзер написал идею на главной. Проверь: `pwd` — папка проекта, `ls` — там лежат AGENTS.md/design.md/README.md.
**Собирай сайт ПРЯМО В ТЕКУЩЕЙ папке:** создавай `index.html`, `style.css`, `script.js` здесь же, в cwd.
**НЕ запускай `./new-project`.****НЕ делай `cd` в другие папки / в корень воркспейса.** Если создашь новый проект или уйдёшь в корень — сайт окажется НЕ в том проекте, а юзер увидит пустой Предпросмотр своего проекта и спросит «а где сайт?». Именно так это ломается.
**НИКОГДА не пиши файлы по АБСОЛЮТНОМУ пути и не конструируй путь из названия проекта** (`/srv/.../<имя>/index.html`). Только ОТНОСИТЕЛЬНЫЕ пути в cwd: `index.html`, `data.json`, `./style.css`. Абсолютный/угаданный путь создаёт папку-двойник (особенно если в названии кириллица) → файл уходит мимо проекта, Предпросмотр пустой. Не уверен, где ты — сделай `pwd` и `ls`, а не угадывай.
### Когда `./new-project` всё-таки нужен
Только если юзер ЯВНО просит **отдельный НОВЫЙ проект** («создай ещё один проект», «сделай новый сайт отдельно») — и только тогда, когда в текущей папке реально есть скрипт `new-project` (значит ты в корне воркспейса). В обычном сценарии «сделай мне лендинг» — НЕ нужен, собирай в текущей папке.
---
## 🌐 Git и публикация
**НЕТ GitHub.** Self-hosted git: **https://git.vibe42.kz**
- Профиль юзера: https://git.vibe42.kz/tjunussov
- Pages (живые лендинги): https://pages.git.vibe42.kz/tjunussov/<repo>/
- Креды уже в `/srv/opencode/workspaces/users/tjunussov/.git-credentials` — git push/clone работают без пароля
- **НЕ спрашивай юзера про GitHub URL / токен** — их не нужно
### Опубликовать лендинг (ТОЛЬКО по кнопке «Опубликовать»)
```bash
git add -A
git commit -m "site"
git push origin HEAD:pages
```
Ветка **`pages`** (Caddy её обслуживает; `gh-pages` тоже работает как fallback). Push → лендинг доступен мгновенно. **Но пушь в `pages` только когда юзер попросил опубликовать (нажал кнопку). Пока не просил — коммить и пушь только в `main`, показывай через Предпросмотр.**
**Если push отклонён («permission denied for writing» и т.п.)** — это проблема git-кредов, она чинится сама при перезаходе. Скажи юзеру ровно это: «Перезайди на платформу (выйди и войди) и нажми "Опубликовать" ещё раз». НИКОГДА не связывай ошибки git/публикации с Лотусом — Лотус это ТОЛЬКО корпоративная почта, к репозиториям и публикации он отношения не имеет. Не выдумывай причин, которых не видишь в выводе команды.
---
## 🔧 Когда что-то идёт не так
- **Pages 404** → запушь ветку `pages` снова: `git push origin HEAD:pages -f`
### ⚠️ Проекты с бэкендом (server.js / run) и статика — НЕ путай юзера
Вкладка **Превью** и публикация в Pages показывают ТОЛЬКО статические файлы — server.js там НЕ работает: формы method=POST и запросы к твоему серверу будут мертвы. Поэтому:
- Прежде чем переделывать работающий статический index.html на серверный рендер (форма POST, шаблоны из server.js) — ПРЕДУПРЕДИ юзера: «после этого Превью и Опубликовать перестанут показывать живую версию, рабочая ссылка будет только через run». Меняй только после его согласия.
- Если проект уже с бэкендом: держи index.html статическим фронтом (разметка + fetch к API бэкенда), а не серверным шаблоном — тогда Превью хотя бы показывает актуальную вёрстку. Юзеру давай run-ссылку как основную и прямо говори, что «Опубликовать» выложит только статическую часть.
- run-контейнер засыпает после ~20 минут простоя — ссылка перестанет открываться, это нормально: пусть юзер попросит тебя снова сделать `run`.
- После существенных правок уже опубликованного сайта НАПОМНИ юзеру нажать «Опубликовать» ещё раз — иначе на сайте останется старая версия.
- **Не дёргай Gitea API типа `/repos/.../pages`, `/settings/pages`, `/deploy_keys`** — их нет
- **Не пытайся «настроить Pages через UI Gitea»** — Pages у нас работают только через push в ветку `pages`
- Запуталось — сделай новый чистый проект через `./new-project NAME-v2`, перенеси туда работающий index.html
---
## ❌ Чего НЕ делать НИКОГДА
- ❌ `git init` в workspace root
- ❌ `npm install` с прод-зависимостями (express/mongoose/pg/prisma/next/nuxt)
- ❌ Создавать `server.js` / `app.py` / `main.go` как backend
- ❌ Использовать `gh` CLI или GitHub API
- ❌ Вызывать Gitea Pages-API (его нет)
- ❌ Долгое отлаживание Pages — почти всегда решение «push HEAD:pages»
- ❌ Просить юзера ввести токен/URL/пароль — всё уже настроено
- ❌ Задавать юзеру 10 вопросов подряд (максимум 2-3 за раз)
- ❌ **Публиковать сам (push в `pages`) без просьбы юзера / кнопки «Опубликовать»** — до публикации показывай результат только через Предпросмотр
- ❌ **Запускать `./new-project` или уходить `cd` из текущей папки проекта** на обычный запрос «сделай сайт» — ты УЖЕ в папке проекта, собирай тут; иначе сайт уедет не в тот проект
- ❌ Показывать юзеру голый код больше 1 раза — ему важен результат, а не как написано
- ❌ Предлагать «давай сначала дизайн в Figma» — мы делаем сразу в HTML
- ❌ Говорить «это сложно» — переформулируй в простое
- ❌ Зависать в обсуждениях — сделай первый вариант грубо, потом итерируй
---
## 🎨 design.md
Рядом лежит `design.md` с готовой палитрой, типографикой и стартер-шаблоном `index.html`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие.

23
CHANGELOG.md Normal file
View File

@ -0,0 +1,23 @@
# Changelog
Все заметные изменения в проекте документируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/),
проект придерживается [Semantic Versioning](https://semver.org/lang/ru/).
## [1.0.0] - 2025-05-09
### Добавлено
- Серверное приложение на Express для автоматизации Kaspi Pay POS.
- 3-шаговая SMS-авторизация (init → send-phone → verify-otp).
- Создание счетов и генерация QR-кодов.
- Просмотр истории транзакций.
- Оформление возвратов.
- Веб-интерфейс (SPA) в `public/`.
- ECDH/ECDSA криптография и TOTP-генерация.
- AES-256-GCM шифрование `vtokenSecret`.
- Поллинг статусов платежей с вебхук-уведомлениями.
- Скрипты ротации ключей (`regen:keypair`, `regen:device`).
- Файловое и консольное логирование.
- Подготовка к open source: SECURITY.md, CONTRIBUTING.md, LICENSE (MIT), GitHub-шаблоны, ESLint, Prettier, EditorConfig, CI.

50
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,50 @@
# Участие в проекте
Спасибо за интерес к проекту Kaspi POS Automation! Мы приветствуем любой вклад.
## Как создать Issue
1. Убедитесь, что аналогичный issue ещё не создан.
2. Используйте соответствующий шаблон (Bug Report или Feature Request).
3. Опишите проблему или предложение максимально подробно.
## Как создать Pull Request
1. Сделайте fork репозитория.
2. Создайте ветку от `main`:
```bash
git checkout -b feature/my-feature
```
3. Внесите изменения и убедитесь, что:
- Код проходит линтинг: `npm run lint`
- Тесты проходят: `npm test`
4. Сделайте коммит с понятным сообщением (см. ниже).
5. Откройте Pull Request с описанием изменений.
## Стиль коммитов
Используйте [Conventional Commits](https://www.conventionalcommits.org/):
```
feat: добавить новый эндпоинт для возвратов
fix: исправить генерацию TOTP при смене часового пояса
docs: обновить README
chore: обновить зависимости
```
## Требования к коду
- **ES Modules** (`import`/`export`).
- Используйте `const` где возможно, стрелочные функции для колбэков.
- Обработка ошибок через `try/catch` с возвратом `{ error }` в JSON.
- Не коммитьте `.env`, `keypair.json`, `device.json` или `*.bak` файлы.
- Запускайте `npm run lint` и `npm run format` перед коммитом.
## Код-ревью
- Все PR проходят ревью перед мержем.
- Будьте открыты к обратной связи и готовы внести правки.
## Сообщение об уязвимостях
Если вы нашли уязвимость, **не создавайте публичный issue**. См. [SECURITY.md](./SECURITY.md).

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 tapter.one
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

147
README.kk.md Normal file
View File

@ -0,0 +1,147 @@
# Kaspi POS Automation
Kaspi Pay API арқылы POS-жүйелер үшін төлемдерді автоматтандыру. Жоба шот-фактуралар жасау, QR-кодтар генерациялау, транзакциялар тарихын қарау және қайтаруларды рәсімдеу үшін серверлік қосымша мен веб-интерфейс ұсынады.
## Сәулет
```
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Web UI │◄─────►│ Express Server │◄─────►│ Kaspi Pay API │
│ (public/) │ │ (server.js) │ │ (entrance/ │
│ │ │ │ │ mtoken/qrpay) │
└──────────────┘ └──────────────────┘ └──────────────────┘
┌─────────┴─────────┐
│ src/ │
│ ├─ config.js │ Кілт жұбы, құрылғы, тұрақтылар
│ ├─ crypto.js │ ECDH, ECDSA, TOTP, AES
│ ├─ helpers.js │ Fetch орауышы, тақырыптар
│ ├─ session.js │ Stateless сессия фабрикасы
│ ├─ logger.js │ Файл және консоль логтары
│ ├─ polling.js │ Төлем статусын сұрау
│ ├─ webhookStore │ Вебхук басқару
│ └─ routes/ │ API маршрут өңдеушілері
│ ├─ auth.js │ SMS авторизация (3 қадам)
│ ├─ invoice.js │ Шот-фактура жасау
│ ├─ qr.js │ QR-код генерациялау
│ ├─ history.js │ Транзакциялар тарихы
│ ├─ refund.js │ Қайтару өңдеу
│ └─ session.js │ Сессия басқару
└───────────────────┘
```
Сервер **авторизациядан кейін stateless** — сессия деректері (шифрланған `vtokenSecret`, `tokenSN`, `profileId`) клиент жағында сақталады және тақырыптар арқылы жіберіледі.
### Webhooks
Сервер жасалған QR және invoice төлемдерінің статустарын автоматты түрде бақылайды (әр 3 секунд сайын polling) және статус өзгерген кезде көрсетілген URL-дарға HTTP POST хабарламаларын жібереді.
- 📡 **Оқиғалар:** `payment.success` · `payment.failed` · `payment.expired`
- ⚙️ **Баптау:** `webhooks.json` файлы ([`webhooks.example.json`](./webhooks.example.json) қараңыз)
- 🔐 **Қолтаңба:** HMAC SHA-256
- 🔄 **Retry:** өсетін кідіріспен 3 әрекетке дейін
> 📖 Толығырақ — [API құжаттамасында](./docs/API.kk.md#webhooks--хабарламалар).
## Талаптар
- Node.js ≥ 20.6
## Жылдам бастау
```bash
# 1. Репозиторийді клондау
git clone https://github.com/tapter-dev/kaspi-pos-automation.git
cd kaspi-pos-automation
# 2. Тәуелділіктерді орнату
npm install
# 3. Шифрлау кілтімен .env жасау
echo "TOKEN_SECRET_KEY=$(openssl rand -hex 32)" > .env
# 4. (Қосымша) Вебхуктарды баптау
cp webhooks.example.json webhooks.json
# webhooks.json файлын өз қажеттіліктеріңізге сай өңдеңіз
# 5. Серверді іске қосу
npm start
```
Бірінші іске қосу кезінде `keypair.json` және `device.json` автоматты түрде генерацияланады.
## Орта айнымалылары
| Айнымалы | Сипаттама | Әдепкі мән | Міндетті |
| ------------------- | ---------------------------------------- | -------------------------- | -------- |
| `TOKEN_SECRET_KEY` | AES-256-GCM үшін 64 символды hex-жол | — | Иә |
| `PORT` | Сервер порты | `3000` | Жоқ |
| `APP_VERSION` | Kaspi Pay қосымша нұсқасы | `4.110.1` | Жоқ |
| `APP_BUILD` | Құрастыру нөмірі | `1099` | Жоқ |
| `APP_PLATFORM` | Құрылғы платформасы | `iOS` | Жоқ |
| `APP_PLATFORM_VER` | ОЖ нұсқасы | `18.5` | Жоқ |
| `APP_LOCALE` | Тіл | `ru-RU` | Жоқ |
| `APP_MODEL` | Құрылғы моделі | `iPhone17,3` | Жоқ |
| `APP_BRAND` | Құрылғы бренді | `Apple` | Жоқ |
| `APP_DEVICE_NAME` | Құрылғы атауы | `iPhone` | Жоқ |
| `APP_SCREEN_W` | Экран ені | `393.0` | Жоқ |
| `APP_SCREEN_H` | Экран биіктігі | `852.0` | Жоқ |
| `APP_CFNETWORK` | CFNetwork нұсқасы | `CFNetwork/3826.500.131` | Жоқ |
| `APP_DARWIN` | Darwin нұсқасы | `Darwin/24.5.0` | Жоқ |
> ⚠️ `APP_*` параметрлері нақты Kaspi Pay клиентіне сәйкес келеді. Kaspi API бұл мәндерді тексереді және белгісіз параметрлері бар сұрауларды қабылдамауы мүмкін.
## Кілттерді ротациялау
```bash
npm run regen:keypair # ECDSA-кілттерді қайта генерациялау
npm run regen:device # Құрылғы идентификаторын қайта генерациялау
```
Ескі файлдар `.bak` ретінде сақталады. Ротациядан кейін бар сессиялар жарамсыз болады.
## Демо-интерфейс (`public/`)
`public/` қалтасында серверімен бірге автоматты түрде іске қосылатын және `http://localhost:3000` мекенжайы бойынша қолжетімді кірістірілген веб-интерфейс (SPA) орналасқан.
**Интерфейс мүмкіндіктері:**
- 🔐 **Авторизация** — Kaspi Pay кассирінің телефон нөмірі арқылы 3 қадамды SMS-flow арқылы кіру (нөмірді енгізу → OTP-код → аяқтау)
- 🧾 **Шот жасау** — клиенттің телефон нөмірі бойынша сома мен түсініктемені көрсете отырып шот жасау
- 📱 **QR-төлем** — нақты уақытта статусты бақылаумен төлем үшін QR-код генерациялау
- 📋 **Операциялар тарихы** — транзакциялар тізімін толық мәліметтерімен қарау
- 💰 **Сатылымдар мен қайтарулар** — сатылым статистикасы және қайтаруларды рәсімдеу
**Файлдар:**
| Файл | Сипаттама |
| --- | --- |
| `public/index.html` | HTML-белгілеу және интерфейс стильдері |
| `public/app.js` | Клиенттік логика (API-шақырулар, күй басқару) |
> Интерфейс API-ді көрсету және тестілеу үшін арналған. Продакшн үшін өз фронтендіңізді пайдалану ұсынылады.
## API құжаттамасы
Барлық API эндпоинттері бойынша толық құжаттама: [`docs/API.kk.md`](./docs/API.kk.md).
## Әзірлеу
```bash
# Линтинг
npm run lint
# Форматтау
npm run format
# Тесттер
npm test
```
## Лицензия
Бұл жоба [MIT](./LICENSE) лицензиясымен таратылады.
## Жобаға қатысу
Біз қоғамдастықтың үлесін қуана қабылдаймыз! Pull request жасамас бұрын [CONTRIBUTING.md](./CONTRIBUTING.md) құжатымен танысыңыз.

149
README.md
View File

@ -1,2 +1,149 @@
# hochu-sdelat-mini-servis-po
# Kaspi POS Automation
Автоматизация платежей для POS-систем через Kaspi Pay API. Проект предоставляет серверное приложение и веб-интерфейс для создания счетов, генерации QR-кодов, просмотра истории транзакций и оформления возвратов.
## Архитектура
```
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Web UI │◄─────►│ Express Server │◄─────►│ Kaspi Pay API │
│ (public/) │ │ (server.js) │ │ (entrance/ │
│ │ │ │ │ mtoken/qrpay) │
└──────────────┘ └──────────────────┘ └──────────────────┘
┌─────────┴─────────┐
│ src/ │
│ ├─ config.js │ Keypair, device, constants
│ ├─ crypto.js │ ECDH, ECDSA, TOTP, AES
│ ├─ helpers.js │ Fetch wrapper, headers
│ ├─ session.js │ Stateless session factory
│ ├─ logger.js │ File & console logging
│ ├─ polling.js │ Payment status polling
│ ├─ webhookStore │ Webhook management
│ └─ routes/ │ API route handlers
│ ├─ auth.js │ SMS auth (3-step)
│ ├─ invoice.js │ Invoice creation
│ ├─ qr.js │ QR code generation
│ ├─ history.js │ Transaction history
│ ├─ refund.js │ Refund processing
│ └─ session.js │ Session management
└───────────────────┘
```
Сервер **stateless после авторизации** — данные сессии (зашифрованный `vtokenSecret`, `tokenSN`, `profileId`) хранятся на стороне клиента и передаются через заголовки.
### Webhooks
Сервер автоматически отслеживает статусы созданных QR- и invoice-платежей (polling каждые 3 сек.) и отправляет HTTP POST-уведомления на указанные URL при изменении статуса.
- 📡 **События:** `payment.success` · `payment.failed` · `payment.expired`
- ⚙️ **Настройка:** файл `webhooks.json` (см. [`webhooks.example.json`](./webhooks.example.json))
- 🔐 **Подпись:** HMAC SHA-256
- 🔄 **Retry:** до 3 попыток с нарастающей задержкой
> 📖 Подробнее — в [документации API](./docs/API.md#webhooks--уведомления).
## Требования
- Node.js ≥ 20.6
## Быстрый старт
```bash
# 1. Клонировать репозиторий
git clone https://github.com/tapter-dev/kaspi-pos-automation.git
cd kaspi-pos-automation
# 2. Установить зависимости
npm install
# 3. Создать .env с ключом шифрования
echo "TOKEN_SECRET_KEY=$(openssl rand -hex 32)" > .env
# 4. (Опционально) Настроить вебхуки
cp webhooks.example.json webhooks.json
# Отредактируйте webhooks.json под свои нужды
# 5. Запустить сервер
npm start
```
При первом запуске автоматически генерируются `keypair.json` и `device.json`.
## Переменные окружения
| Переменная | Описание | По умолчанию | Обязательная |
| ------------------ | ---------------------------------------- | -------------------------- | ------------ |
| `TOKEN_SECRET_KEY` | 64-символьная hex-строка для AES-256-GCM | — | Да |
| `PORT` | Порт сервера | `3000` | Нет |
| `APP_VERSION` | Версия приложения Kaspi Pay | `4.110.1` | Нет |
| `APP_BUILD` | Номер сборки | `1099` | Нет |
| `APP_PLATFORM` | Платформа устройства | `iOS` | Нет |
| `APP_PLATFORM_VER` | Версия ОС | `18.5` | Нет |
| `APP_LOCALE` | Локаль | `ru-RU` | Нет |
| `APP_MODEL` | Модель устройства | `iPhone17,3` | Нет |
| `APP_BRAND` | Бренд устройства | `Apple` | Нет |
| `APP_DEVICE_NAME` | Имя устройства | `iPhone` | Нет |
| `APP_SCREEN_W` | Ширина экрана | `393.0` | Нет |
| `APP_SCREEN_H` | Высота экрана | `852.0` | Нет |
| `APP_CFNETWORK` | Версия CFNetwork | `CFNetwork/3826.500.131` | Нет |
| `APP_DARWIN` | Версия Darwin | `Darwin/24.5.0` | Нет |
> ⚠️ Параметры `APP_*` соответствуют реальному клиенту Kaspi Pay. API Kaspi валидирует эти значения и может отклонить запросы с неизвестными параметрами. Обновляйте их при выходе новой версии приложения.
## Ротация ключей
```bash
npm run regen:keypair # Перегенерация ECDSA-ключей
npm run regen:device # Перегенерация идентификатора устройства
```
Старые файлы сохраняются как `.bak`. После ротации существующие сессии становятся недействительными.
## Демо-интерфейс (`public/`)
В папке `public/` находится встроенный веб-интерфейс (SPA), который запускается автоматически вместе с сервером и доступен по адресу `http://localhost:3000`.
**Возможности интерфейса:**
- 🔐 **Авторизация** — вход по номеру телефона кассира Kaspi Pay через 3-шаговый SMS-flow (ввод номера → OTP-код → завершение)
- 🧾 **Выставление счёта** — создание счёта по номеру телефона клиента с указанием суммы и комментария
- 📱 **QR-оплата** — генерация QR-кода для оплаты с отслеживанием статуса в реальном времени
- 📋 **История операций** — просмотр списка транзакций с детализацией
- 💰 **Продажи и возвраты** — статистика продаж и оформление возвратов
**Файлы:**
| Файл | Описание |
| --- | --- |
| `public/index.html` | HTML-разметка и стили интерфейса |
| `public/app.js` | Клиентская логика (API-вызовы, управление состоянием) |
> Интерфейс предназначен для демонстрации и тестирования API. Для продакшена рекомендуется использовать собственный фронтенд.
## API документация
Подробная документация по всем эндпоинтам API: [`docs/API.md`](./docs/API.md).
📗 Документация также доступна на казахском языке: [`README.kk.md`](./README.kk.md) | [`docs/API.kk.md`](./docs/API.kk.md)
## Разработка
```bash
# Линтинг
npm run lint
# Форматирование
npm run format
# Тесты
npm test
```
## Лицензия
Этот проект распространяется под лицензией [MIT](./LICENSE).
## Участие в проекте
Мы приветствуем вклад сообщества! Пожалуйста, ознакомьтесь с [CONTRIBUTING.md](./CONTRIBUTING.md) перед созданием pull request.

30
SECURITY.md Normal file
View File

@ -0,0 +1,30 @@
# Security Policy
## Reporting a Vulnerability
If you discover a security vulnerability in this project, please report it responsibly.
**Do NOT open a public GitHub issue.**
Instead, send an email to: **admin@everything.kz**
Please include:
- A description of the vulnerability.
- Steps to reproduce the issue.
- Any relevant logs or screenshots.
We will acknowledge your report within **48 hours** and aim to provide a fix or mitigation within **7 days**.
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 1.x | :white_check_mark: |
## Security Best Practices
- Never commit `.env`, `keypair.json`, `device.json`, or `*.bak` files.
- Always set `TOKEN_SECRET_KEY` via environment variable — the app will refuse to start without it.
- Rotate keys periodically using `npm run regen:keypair` and `npm run regen:device`.
- Keep dependencies up to date (`npm audit`).

142
design.md Normal file
View File

@ -0,0 +1,142 @@
<!-- vibe42-design-version: v2-recipes-2026-07-07 -->
# Design system — Vibe42 песочница
Базовые цвета и типографика для лендингов. Начинай с этого, можно отклоняться.
## Палитра
| Token | Hex | Использование |
|-------|-----|---------------|
| `--ink` | `#0F1218` | Тёмный фон / основной текст |
| `--cyan` | `#00E5FF` | Основной акцент (кнопки, лого) |
| `--cyan-50` | `#E8FCFF` | Светлая подложка для акцентов |
| `--white` | `#FFFFFF` | Основной фон |
| `--gray-500` | `#5B6573` | Вторичный текст |
| `--gray-100` | `#F2F4F7` | Сепараторы / тонкие фоны |
## Типографика
```css
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
```
| Уровень | Размер | Вес | line-height |
|---------|--------|-----|-------------|
| h1 (hero) | 56px | 800 | 1.05 |
| h2 (section) | 36px | 700 | 1.15 |
| h3 | 22px | 700 | 1.3 |
| body | 17px | 400 | 1.6 |
На мобиле — h1 → 36px, h2 → 28px.
## Лейаут
- max-width контента: **1140px**
- секция: `padding: 80px 24px` (мобила: `48px 20px`)
- border-radius: `8px` (кнопки), `16px` (карточки)
## Стартер `index.html`
```html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Мой проект</title>
<style>
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7}
*{box-sizing:border-box;margin:0;padding:0}
body{font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:var(--white)}
.container{max-width:1140px;margin:0 auto;padding:80px 24px}
.hero{background:var(--ink);color:var(--white)}
.hero h1{font-size:56px;font-weight:800;line-height:1.05;margin-bottom:24px}
.hero p{font-size:20px;color:#9aa3b2;max-width:600px;margin-bottom:32px}
.btn{display:inline-block;background:var(--cyan);color:var(--ink);padding:14px 28px;border-radius:8px;font-weight:700;text-decoration:none}
.section h2{font-size:36px;font-weight:700;margin-bottom:24px}
.card{background:var(--gray-100);border-radius:16px;padding:32px;margin-bottom:16px}
@media (max-width:640px){.hero h1{font-size:36px}.section h2{font-size:28px}.container{padding:48px 20px}}
</style>
</head>
<body>
<section class="hero"><div class="container">
<h1>Заголовок</h1>
<p>Подзаголовок — пара предложений о чём это.</p>
<a class="btn" href="#section">Начать</a>
</div></section>
<section id="section" class="section"><div class="container">
<h2>Секция</h2>
<div class="card">Контент карточки.</div>
<div class="card">Контент карточки.</div>
</div></section>
</body>
</html>
```
## Что НЕ юзать
- Bootstrap / Material UI / Chakra / Ant Design — слишком тяжело
- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG
- jQuery — vanilla JS достаточно
## Что МОЖНО (если очень надо)
- **Tailwind через CDN**: `<script src="https://cdn.tailwindcss.com"></script>`
- **Placeholder картинки**: `https://picsum.photos/800/600`
- **Google Fonts через `<link>`** в head
## 📦 Готовые рецепты (используй их, не изобретай)
### Фото без ключей
- Детерминированные (одно и то же слово → одна и та же картинка): `https://picsum.photos/seed/coffee/800/600`. Меняй `coffee` на любое слово-seed.
- Тематические (по тегу): `https://loremflickr.com/800/600/coffee` — тег прямо в URL (`/coffee`, `/mountains`, `/food`). Тема не гарантируется на 100%, но обычно в кассу.
### Кнопка WhatsApp
```html
<a href="https://wa.me/7XXXXXXXXXX?text=Здравствуйте!" style="display:inline-block;background:#25D366;color:#fff;padding:12px 22px;border-radius:8px;font-weight:700;text-decoration:none">Написать в WhatsApp</a>
```
`7XXXXXXXXXX` — номер в межд. формате без `+`. `text=` — предзаполненное сообщение.
### Форма заявки без бэкенда
```html
<form action="https://formsubmit.co/EMAIL" method="POST">
<input name="name" placeholder="Имя" required>
<input name="phone" placeholder="Телефон" required>
<button type="submit">Отправить</button>
</form>
```
Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует, письмо-подтверждение придёт один раз).
### Google Fonts (максимум 2 шрифта: один display + один text)
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;700&family=Unbounded:wght@700&display=swap" rel="stylesheet">
```
В CSS: `font-family:'Unbounded',sans-serif` для заголовков, `'Manrope',sans-serif` для текста.
### Favicon-эмодзи (inline SVG data-URI, без файла)
```html
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☕</text></svg>">
```
Меняй эмодзи `☕` на подходящее теме.
### Карта (Google Maps embed, без ключа)
На maps.google.com найди место → «Поделиться» → «Встроить карту» → скопируй iframe. Вставь как есть:
```html
<iframe src="https://www.google.com/maps/embed?pb=..." width="100%" height="320" style="border:0;border-radius:12px" loading="lazy"></iframe>
```
### Плавные появления при скролле (IntersectionObserver)
```html
<style>
.reveal{opacity:0;transform:translateY(20px);transition:opacity .6s ease,transform .6s ease}
.reveal.in{opacity:1;transform:none}
</style>
<script>
var io=new IntersectionObserver(function(entries){
entries.forEach(function(e){ if(e.isIntersecting) e.target.classList.add('in'); });
},{threshold:.15});
document.querySelectorAll('.reveal').forEach(function(el){ io.observe(el); });
</script>
```
Добавь класс `reveal` любой секции/карточке — появится плавно при прокрутке.

693
docs/API.kk.md Normal file
View File

@ -0,0 +1,693 @@
# 📖 API құжаттамасы
Kaspi POS Automation Kaspi Pay төлемдерімен жұмыс істеу үшін REST API ұсынады: SMS арқылы авторизация, шот-фактуралар жасау, QR-төлем, операциялар тарихы және қайтарулар.
**Base URL:** `http://localhost:3000`
---
## Мазмұны
- [Аутентификация](#аутентификация)
- [Сессия тақырыптары](#сессия-тақырыптары)
- [Health Check](#health-check)
- [Auth — Авторизация](#auth--авторизация)
- [POST /api/auth/init](#post-apiauthinit)
- [POST /api/auth/send-phone](#post-apiauthsend-phone)
- [POST /api/auth/verify-otp](#post-apiauthverify-otp)
- [POST /api/auth/session](#post-apiauthsession)
- [POST /api/auth/logout](#post-apiauthlogout)
- [Invoice — Шот-фактуралар](#invoice--шот-фактуралар)
- [GET /api/invoice/client-info](#get-apiinvoiceclient-info)
- [POST /api/invoice/create](#post-apiinvoicecreate)
- [GET /api/invoice/details](#get-apiinvoicedetails)
- [POST /api/invoice/cancel](#post-apiinvoicecancel)
- [POST /api/invoice/history](#post-apiinvoicehistory)
- [QR — QR-төлем](#qr--qr-төлем)
- [POST /api/qr/create](#post-apiqrcreate)
- [GET /api/qr/status](#get-apiqrstatus)
- [History — Операциялар тарихы](#history--операциялар-тарихы)
- [POST /api/history/operations](#post-apihistoryoperations)
- [POST /api/history/details](#post-apihistorydetails)
- [Refund — Қайтарулар](#refund--қайтарулар)
- [POST /api/refund/create](#post-apirefundcreate)
- [Session — Сессияны тексеру](#session--сессияны-тексеру)
- [GET /api/session/check](#get-apisessioncheck)
- [Webhooks — Хабарламалар](#webhooks--хабарламалар)
- [Баптау](#баптау)
- [Оқиғалар](#оқиғалар)
- [Payload форматы](#payload-форматы)
- [Қолтаңба (HMAC)](#қолтаңба-hmac)
- [Қайта жіберу (Retry)](#қайта-жіберу-retry)
---
## Аутентификация
API 3 қадамды SMS-авторизацияны пайдаланады. Сәтті авторизациядан кейін клиент `tokenSN` және `vtokenSecret` алады, олар барлық қорғалған эндпоинттер үшін тақырыптарда жіберіледі.
### Сессия тақырыптары
`/api/auth/*` және `/health` басқа барлық эндпоинттер келесі тақырыптарды талап етеді:
| Тақырып | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `X-Token-SN` | `string` | ✅ | Авторизация кезінде алынған сессия токені |
| `X-Vtoken-Secret` | `string` | ✅ | Шифрланған сессия құпиясы |
| `X-Profile-Id` | `string` | ❌ | Ұйым профилінің ID-сі |
---
## Health Check
### `GET /health`
Сервердің жұмыс қабілеттілігін тексеру.
**Жауап:**
```json
{ "status": "ok" }
```
---
## Auth — Авторизация
Kaspi SMS-коды арқылы үш қадамды авторизация процесі.
> ⚠️ **Маңызды:** Кіру үшін Kaspi Pay **кассирінің** аккаунтының телефон нөмірін пайдаланыңыз.
### `POST /api/auth/init`
Авторизация процесін инициализациялау. Келесі қадамдар үшін `processId` қайтарады.
**Сұраныс денесі:** қажет емес
**Сұраныс мысалы:**
```bash
curl -X POST http://localhost:3000/api/auth/init
```
**Сәтті жауап:**
```json
{
"success": true,
"processId": "abc123-...",
"view": "EnterPhoneNumber",
"body": { ... }
}
```
---
### `POST /api/auth/send-phone`
Телефон нөмірін жіберу — SMS-код жіберуді бастайды.
**Сұраныс денесі:**
| Өріс | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `phoneNumber` | `string` | ✅ | Телефон нөмірі (формат: `7XXXXXXXXXX`) |
| `processId` | `string` | ✅ | `/api/auth/init` процесінің ID-сі |
**Сұраныс мысалы:**
```bash
curl -X POST http://localhost:3000/api/auth/send-phone \
-H "Content-Type: application/json" \
-d '{"phoneNumber": "77001234567", "processId": "abc123-..."}'
```
**Сәтті жауап:**
```json
{
"success": true,
"processId": "abc123-...",
"desc": "Код отправлен на номер +7 700 *** ** 67",
"view": "EnterOtp",
"body": { ... }
}
```
---
### `POST /api/auth/verify-otp`
SMS-кодты растау. Сәтті болған жағдайда авторизацияны автоматты түрде аяқтайды және сессия деректерін қайтарады.
**Сұраныс денесі:**
| Өріс | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `otp` | `string` | ✅ | SMS-код |
| `processId` | `string` | ✅ | `/api/auth/init` процесінің ID-сі |
**Сұраныс мысалы:**
```bash
curl -X POST http://localhost:3000/api/auth/verify-otp \
-H "Content-Type: application/json" \
-d '{"otp": "1234", "processId": "abc123-..."}'
```
**Сәтті жауап:**
```json
{
"success": true,
"processId": "abc123-...",
"step": "finished",
"message": "OTP verified and finish completed",
"tokenSN": "TOKEN_SN_VALUE",
"vtokenSecret": "ENCRYPTED_SECRET",
"profileId": 12345,
"organizationId": 67890,
"orgName": "ЖК Иванов",
"phone": "77001234567",
"organizations": [ ... ]
}
```
> ⚠️ `tokenSN` және `vtokenSecret` сақтаңыз — олар барлық кейінгі сұраныстар үшін қажет.
---
### `POST /api/auth/session`
Токеннің бар-жоғын тексеру (клиенттік тексеру).
**Сұраныс денесі:**
| Өріс | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `tokenSN` | `string` | ❌ | Сессия токені |
**Жауап:**
```json
{
"authenticated": true,
"tokenSN": "TOKEN_SN_VALUE"
}
```
---
### `POST /api/auth/logout`
Сессияны аяқтау.
**Сұраныс денесі:** қажет емес
**Жауап:**
```json
{ "success": true }
```
---
## Invoice — Шот-фактуралар
Клиенттің телефон нөмірі бойынша төлем шот-фактураларын жасау.
> 🔒 Барлық эндпоинттер [сессия тақырыптарын](#сессия-тақырыптары) талап етеді.
### `GET /api/invoice/client-info`
Телефон нөмірі бойынша клиент туралы ақпарат алу.
**Query-параметрлері:**
| Параметр | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `phoneNumber` | `string` | ✅ | Клиенттің телефон нөмірі |
**Сұраныс мысалы:**
```bash
curl "http://localhost:3000/api/invoice/client-info?phoneNumber=77001234567" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-H "X-Profile-Id: ..."
```
---
### `POST /api/invoice/create`
Төлем шот-фактурасын жасау.
**Сұраныс денесі:**
| Өріс | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `phoneNumber` | `string` | ✅ | Клиенттің телефон нөмірі |
| `amount` | `number` | ✅ | Теңгемен сома |
| `comment` | `string` | ❌ | Төлемге түсініктеме |
**Сұраныс мысалы:**
```bash
curl -X POST http://localhost:3000/api/invoice/create \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-H "X-Profile-Id: ..." \
-d '{"phoneNumber": "77001234567", "amount": 1000, "comment": "Тапсырыс #42 төлемі"}'
```
**Сәтті жауап:**
```json
{
"StatusCode": 0,
"Data": {
"Id": 123456,
"Status": "RemotePaymentCreated",
"Amount": 1000,
"ClientMobile": "77001234567",
"ReceiptUrl": "https://...",
"OrderNumber": "..."
}
}
```
---
### `GET /api/invoice/details`
Шот-фактура мәліметтерін алу.
**Query-параметрлері:**
| Параметр | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `operationId` | `string` | ✅ | Операция ID-сі |
**Сұраныс мысалы:**
```bash
curl "http://localhost:3000/api/invoice/details?operationId=123456" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..."
```
---
### `POST /api/invoice/cancel`
Жасалған шот-фактураны болдырмау.
**Сұраныс денесі:**
| Өріс | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `operationId` | `string` | ✅ | Болдырмау үшін операция ID-сі |
**Сұраныс мысалы:**
```bash
curl -X POST http://localhost:3000/api/invoice/cancel \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-d '{"operationId": "123456"}'
```
---
### `POST /api/invoice/history`
Жасалған шот-фактуралар тарихын алу (соңғы 20).
**Сұраныс денесі:** қажет емес (бос JSON `{}`)
**Сұраныс мысалы:**
```bash
curl -X POST http://localhost:3000/api/invoice/history \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-d '{}'
```
---
## QR — QR-төлем
Kaspi Pay арқылы төлем үшін QR-кодтар генерациялау.
> 🔒 Барлық эндпоинттер [сессия тақырыптарын](#сессия-тақырыптары) талап етеді.
### `POST /api/qr/create`
Төлем үшін QR-токен жасау.
**Сұраныс денесі:**
| Өріс | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `amount` | `number` | ✅ | Теңгемен сома |
| `latitude` | `number` | ❌ | Ендік (әдепкі: Алматы) |
| `longitude` | `number` | ❌ | Бойлық (әдепкі: Алматы) |
**Сұраныс мысалы:**
```bash
curl -X POST http://localhost:3000/api/qr/create \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-H "X-Profile-Id: ..." \
-d '{"amount": 500}'
```
**Сәтті жауап:**
```json
{
"StatusCode": 0,
"Data": {
"QrOperationId": 789012,
"QrToken": "https://pay.kaspi.kz/pay/...",
"ExpireDate": "2025-01-01T12:05:00",
"Amount": 500,
"ReceiptUrl": "https://..."
}
}
```
> 💡 `QrToken` төлем сілтемесін қамтиды — оны QR-кодқа түрлендіруге болады.
---
### `GET /api/qr/status`
QR-төлем статусын тексеру.
**Query-параметрлері:**
| Параметр | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `qrOperationId` | `string` | ✅ | `/api/qr/create` QR-операциясының ID-сі |
**Сұраныс мысалы:**
```bash
curl "http://localhost:3000/api/qr/status?qrOperationId=789012" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..."
```
---
## History — Операциялар тарихы
Барлық операциялар тарихын қарау (QR + шот-фактуралар).
> 🔒 Барлық эндпоинттер [сессия тақырыптарын](#сессия-тақырыптары) талап етеді.
### `POST /api/history/operations`
Кезең бойынша операциялар тізімін алу.
**Сұраныс денесі:**
| Өріс | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `endDate` | `string` | ✅ | Аяқталу күні (формат: `YYYY-MM-DD`) |
| `lastTransactionDate` | `string` | ❌ | Соңғы транзакция күні (пагинация үшін) |
| `statementPeriodCode` | `number` | ❌ | Кезең коды (әдепкі: `0`) |
**Сұраныс мысалы:**
```bash
curl -X POST http://localhost:3000/api/history/operations \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-d '{"endDate": "2025-01-15"}'
```
---
### `POST /api/history/details`
Нақты операцияның мәліметтерін алу.
**Сұраныс денесі:**
| Өріс | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `id` | `number` | ✅ | Операция ID-сі |
| `operationMethod` | `number` | ❌ | Операция әдісі (әдепкі: `0`) |
**Сұраныс мысалы:**
```bash
curl -X POST http://localhost:3000/api/history/details \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-d '{"id": 123456}'
```
---
## Refund — Қайтарулар
Бұрын жүргізілген операция бойынша қаражатты қайтару.
> 🔒 Барлық эндпоинттер [сессия тақырыптарын](#сессия-тақырыптары) талап етеді.
### `POST /api/refund/create`
Қайтару жасау.
**Сұраныс денесі:**
| Өріс | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `qrOperationId` | `number` | ✅ | Қайтару үшін операция ID-сі |
| `returnAmount` | `number` | ✅ | Теңгемен қайтару сомасы |
**Сұраныс мысалы:**
```bash
curl -X POST http://localhost:3000/api/refund/create \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-d '{"qrOperationId": 789012, "returnAmount": 500}'
```
---
## Session — Сессияны тексеру
### `GET /api/session/check`
Kaspi API-ге сұраныс арқылы ағымдағы сессияның жарамдылығын тексеру.
> 🔒 [Сессия тақырыптарын](#сессия-тақырыптары) талап етеді.
**Сұраныс мысалы:**
```bash
curl "http://localhost:3000/api/session/check" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..."
```
**Белсенді сессия:**
```json
{ "active": true }
```
**Белсенді емес сессия:**
```json
{
"active": false,
"error": "Session rejected by Kaspi API.",
"code": 401,
"details": { ... }
}
```
---
## Қате кодтары
Барлық эндпоинттер қателерді келесі форматта қайтарады:
```json
{ "error": "Қатенің сипаттамасы" }
```
| HTTP-код | Сипаттама |
|---|---|
| `400` | Міндетті параметрлер жоқ |
| `401` | Сессия тақырыптары жоқ немесе жарамсыз |
| `500` | Сервердің ішкі қатесі немесе Kaspi API қатесі |
---
## Webhooks — Хабарламалар
Жүйе жасалған QR және invoice төлемдерінің статустарын автоматты түрде бақылайды (әр 3 секунд сайын polling) және төлем статусы өзгерген кезде көрсетілген URL-дарға HTTP POST хабарламаларын (webhooks) жібереді.
### Баптау
Вебхуктар жобаның түбіріндегі `webhooks.json` файлында баптаулады. Файл объектілер массивін қамтиды:
```json
[
{
"url": "https://example.com/webhook",
"events": ["payment.success", "payment.failed", "payment.expired"],
"secret": "your-webhook-secret"
}
]
```
| Өріс | Түрі | Міндетті | Сипаттама |
|---|---|---|---|
| `url` | `string` | ✅ | Хабарламалар жіберілетін URL |
| `events` | `string[]` | ✅ | Жазылу оқиғаларының тізімі |
| `secret` | `string` | ❌ | HMAC қолтаңбасы үшін құпия (ұсынылады) |
> 💡 Бастау үшін `webhooks.example.json``webhooks.json` көшіріп, өңдеңіз.
Әр түрлі URL және оқиғалармен бірнеше вебхук көрсетуге болады:
```json
[
{
"url": "https://my-crm.com/kaspi-hook",
"events": ["payment.success"],
"secret": "crm-secret-key"
},
{
"url": "https://my-accounting.com/hook",
"events": ["payment.success", "payment.failed", "payment.expired"],
"secret": "accounting-secret"
}
]
```
### Оқиғалар
| Оқиға | Сипаттама | Қашан іске қосылады |
|---|---|---|
| `payment.success` | Төлем сәтті өтті | QR: `Processed` статусы; Invoice: `Processed` статусы |
| `payment.failed` | Төлем қабылданбады / бас тартылды | QR: `CancelledByUser`, `Rejected`, `Error` және т.б.; Invoice: `RemotePaymentCanceled`, `RemotePaymentRejected` |
| `payment.expired` | Төлем уақыты аяқталды | QR: `QrTokenDiscarded`, `Expired`; Invoice: `Expired` |
### Payload форматы
Оқиға іске қосылғанда әрбір жазылған URL-ға JSON денесі бар POST сұрау жіберіледі:
```json
{
"event": "payment.success",
"paymentId": "123456",
"type": "qr",
"status": "Processed",
"statusDesc": "Операция сәтті өтті",
"amount": 5000,
"qrToken": "QR-TOKEN-...",
"receiptUrl": "https://...",
"orderNumber": "ORDER-001",
"data": { ... },
"timestamp": "2026-05-10T00:00:00.000Z"
}
```
| Өріс | Түрі | Сипаттама |
|---|---|---|
| `event` | `string` | Оқиға атауы (`payment.success`, `payment.failed`, `payment.expired`) |
| `paymentId` | `string` | Төлем ID-сі (QR operationId немесе invoice operationId) |
| `type` | `string` | Төлем түрі: `qr` немесе `invoice` |
| `status` | `string` | Kaspi API-ден финалды статус |
| `statusDesc` | `string` | Статус сипаттамасы |
| `amount` | `number\|null` | Төлем сомасы теңгемен |
| `qrToken` | `string\|null` | QR-токен (тек QR-төлемдер үшін) |
| `receiptUrl` | `string\|null` | Чекке сілтеме |
| `orderNumber` | `string\|null` | Тапсырыс нөмірі |
| `data` | `object` | Kaspi API-ден толық жауап деректері |
| `timestamp` | `string` | Хабарлама жіберу уақыты (ISO 8601) |
### Қолтаңба (HMAC)
Әрбір сұрау вебхук конфигурациясындағы `secret` көмегімен HMAC SHA-256 арқылы қолтаңбаланады. Қолтаңба тақырыпта жіберіледі:
```
X-Webhook-Signature: sha256=<hex-digest>
```
**Қабылдаушы жағында қолтаңбаны тексеру (Node.js):**
```javascript
import crypto from 'crypto';
const verifySignature = (body, signature, secret) => {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
};
// Сұрау өңдеушісінде:
const rawBody = JSON.stringify(req.body); // немесе raw body пайдаланыңыз
const sig = req.headers['x-webhook-signature'];
if (!verifySignature(rawBody, sig, 'your-webhook-secret')) {
return res.status(401).send('Invalid signature');
}
```
### Қайта жіберу (Retry)
Вебхук жеткізілмесе (желі қатесі, таймаут, HTTP қатесі), жүйе **3 әрекетке** дейін өсетін кідіріспен орындайды:
| Әрекет | Кідіріс |
|---|---|
| 1-ші (бірінші) | Бірден |
| 2-ші | 5 секунд |
| 3-ші | 30 секунд |
- Сұрау таймауты: **10 секунд**.
- Қайта жіберу кезегі `webhook-retries.json` файлында сақталады және сервер қайта іске қосылғанда жоғалмайды.
- 3 сәтсіз әрекеттен кейін хабарлама жойылады (қате логқа жазылады).
---
## Пайдаланудың типтік сценарийі
```
1. POST /api/auth/init → processId алу
2. POST /api/auth/send-phone → SMS жіберу
3. POST /api/auth/verify-otp → кодты растау → tokenSN + vtokenSecret алу
4. POST /api/qr/create → төлем үшін QR жасау
5. GET /api/qr/status → төлем статусын тексеру
— немесе —
4. POST /api/invoice/create → телефон нөмірі бойынша шот-фактура жасау
5. GET /api/invoice/details → шот-фактура статусын тексеру
6. POST /api/refund/create → қаражатты қайтару (қажет болған жағдайда)
```

693
docs/API.md Normal file
View File

@ -0,0 +1,693 @@
# 📖 API Documentation
Kaspi POS Automation предоставляет REST API для работы с платежами Kaspi Pay: авторизация по SMS, выставление счетов, QR-оплата, история операций и возвраты.
**Base URL:** `http://localhost:3000`
---
## Содержание
- [Аутентификация](#аутентификация)
- [Заголовки сессии](#заголовки-сессии)
- [Health Check](#health-check)
- [Auth — Авторизация](#auth--авторизация)
- [POST /api/auth/init](#post-apiauthinit)
- [POST /api/auth/send-phone](#post-apiauthsend-phone)
- [POST /api/auth/verify-otp](#post-apiauthverify-otp)
- [POST /api/auth/session](#post-apiauthsession)
- [POST /api/auth/logout](#post-apiauthlogout)
- [Invoice — Счета](#invoice--счета)
- [GET /api/invoice/client-info](#get-apiinvoiceclient-info)
- [POST /api/invoice/create](#post-apiinvoicecreate)
- [GET /api/invoice/details](#get-apiinvoicedetails)
- [POST /api/invoice/cancel](#post-apiinvoicecancel)
- [POST /api/invoice/history](#post-apiinvoicehistory)
- [QR — QR-оплата](#qr--qr-оплата)
- [POST /api/qr/create](#post-apiqrcreate)
- [GET /api/qr/status](#get-apiqrstatus)
- [History — История операций](#history--история-операций)
- [POST /api/history/operations](#post-apihistoryoperations)
- [POST /api/history/details](#post-apihistorydetails)
- [Refund — Возвраты](#refund--возвраты)
- [POST /api/refund/create](#post-apirefundcreate)
- [Session — Проверка сессии](#session--проверка-сессии)
- [GET /api/session/check](#get-apisessioncheck)
- [Webhooks — Уведомления](#webhooks--уведомления)
- [Настройка](#настройка)
- [События](#события)
- [Формат payload](#формат-payload)
- [Подпись (HMAC)](#подпись-hmac)
- [Повторные попытки (Retry)](#повторные-попытки-retry)
---
## Аутентификация
API использует 3-шаговую SMS-авторизацию. После успешной авторизации клиент получает `tokenSN` и `vtokenSecret`, которые передаются в заголовках для всех защищённых эндпоинтов.
### Заголовки сессии
Все эндпоинты кроме `/api/auth/*` и `/health` требуют следующие заголовки:
| Заголовок | Тип | Обязательный | Описание |
|---|---|---|---|
| `X-Token-SN` | `string` | ✅ | Токен сессии, полученный при авторизации |
| `X-Vtoken-Secret` | `string` | ✅ | Зашифрованный секрет сессии |
| `X-Profile-Id` | `string` | ❌ | ID профиля организации |
---
## Health Check
### `GET /health`
Проверка работоспособности сервера.
**Ответ:**
```json
{ "status": "ok" }
```
---
## Auth — Авторизация
Трёхшаговый процесс авторизации через SMS-код Kaspi.
> ⚠️ **Важно:** Для входа используйте номер телефона аккаунта **кассира** Kaspi Pay.
### `POST /api/auth/init`
Инициализация процесса авторизации. Возвращает `processId` для последующих шагов.
**Тело запроса:** не требуется
**Пример запроса:**
```bash
curl -X POST http://localhost:3000/api/auth/init
```
**Успешный ответ:**
```json
{
"success": true,
"processId": "abc123-...",
"view": "EnterPhoneNumber",
"body": { ... }
}
```
---
### `POST /api/auth/send-phone`
Отправка номера телефона — инициирует отправку SMS-кода.
**Тело запроса:**
| Поле | Тип | Обязательный | Описание |
|---|---|---|---|
| `phoneNumber` | `string` | ✅ | Номер телефона (формат: `7XXXXXXXXXX`) |
| `processId` | `string` | ✅ | ID процесса из `/api/auth/init` |
**Пример запроса:**
```bash
curl -X POST http://localhost:3000/api/auth/send-phone \
-H "Content-Type: application/json" \
-d '{"phoneNumber": "77001234567", "processId": "abc123-..."}'
```
**Успешный ответ:**
```json
{
"success": true,
"processId": "abc123-...",
"desc": "Код отправлен на номер +7 700 *** ** 67",
"view": "EnterOtp",
"body": { ... }
}
```
---
### `POST /api/auth/verify-otp`
Подтверждение SMS-кода. При успехе автоматически завершает авторизацию и возвращает данные сессии.
**Тело запроса:**
| Поле | Тип | Обязательный | Описание |
|---|---|---|---|
| `otp` | `string` | ✅ | SMS-код |
| `processId` | `string` | ✅ | ID процесса из `/api/auth/init` |
**Пример запроса:**
```bash
curl -X POST http://localhost:3000/api/auth/verify-otp \
-H "Content-Type: application/json" \
-d '{"otp": "1234", "processId": "abc123-..."}'
```
**Успешный ответ:**
```json
{
"success": true,
"processId": "abc123-...",
"step": "finished",
"message": "OTP verified and finish completed",
"tokenSN": "TOKEN_SN_VALUE",
"vtokenSecret": "ENCRYPTED_SECRET",
"profileId": 12345,
"organizationId": 67890,
"orgName": "ИП Иванов",
"phone": "77001234567",
"organizations": [ ... ]
}
```
> ⚠️ Сохраните `tokenSN` и `vtokenSecret` — они нужны для всех последующих запросов.
---
### `POST /api/auth/session`
Проверка наличия токена (клиентская проверка).
**Тело запроса:**
| Поле | Тип | Обязательный | Описание |
|---|---|---|---|
| `tokenSN` | `string` | ❌ | Токен сессии |
**Ответ:**
```json
{
"authenticated": true,
"tokenSN": "TOKEN_SN_VALUE"
}
```
---
### `POST /api/auth/logout`
Завершение сессии.
**Тело запроса:** не требуется
**Ответ:**
```json
{ "success": true }
```
---
## Invoice — Счета
Выставление счетов на оплату по номеру телефона клиента.
> 🔒 Все эндпоинты требуют [заголовки сессии](#заголовки-сессии).
### `GET /api/invoice/client-info`
Получение информации о клиенте по номеру телефона.
**Query-параметры:**
| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| `phoneNumber` | `string` | ✅ | Номер телефона клиента |
**Пример запроса:**
```bash
curl "http://localhost:3000/api/invoice/client-info?phoneNumber=77001234567" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-H "X-Profile-Id: ..."
```
---
### `POST /api/invoice/create`
Создание счёта на оплату.
**Тело запроса:**
| Поле | Тип | Обязательный | Описание |
|---|---|---|---|
| `phoneNumber` | `string` | ✅ | Номер телефона клиента |
| `amount` | `number` | ✅ | Сумма в тенге |
| `comment` | `string` | ❌ | Комментарий к платежу |
**Пример запроса:**
```bash
curl -X POST http://localhost:3000/api/invoice/create \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-H "X-Profile-Id: ..." \
-d '{"phoneNumber": "77001234567", "amount": 1000, "comment": "Оплата заказа #42"}'
```
**Успешный ответ:**
```json
{
"StatusCode": 0,
"Data": {
"Id": 123456,
"Status": "RemotePaymentCreated",
"Amount": 1000,
"ClientMobile": "77001234567",
"ReceiptUrl": "https://...",
"OrderNumber": "..."
}
}
```
---
### `GET /api/invoice/details`
Получение деталей счёта.
**Query-параметры:**
| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| `operationId` | `string` | ✅ | ID операции |
**Пример запроса:**
```bash
curl "http://localhost:3000/api/invoice/details?operationId=123456" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..."
```
---
### `POST /api/invoice/cancel`
Отмена выставленного счёта.
**Тело запроса:**
| Поле | Тип | Обязательный | Описание |
|---|---|---|---|
| `operationId` | `string` | ✅ | ID операции для отмены |
**Пример запроса:**
```bash
curl -X POST http://localhost:3000/api/invoice/cancel \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-d '{"operationId": "123456"}'
```
---
### `POST /api/invoice/history`
Получение истории выставленных счетов (последние 20).
**Тело запроса:** не требуется (пустой JSON `{}`)
**Пример запроса:**
```bash
curl -X POST http://localhost:3000/api/invoice/history \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-d '{}'
```
---
## QR — QR-оплата
Генерация QR-кодов для оплаты через Kaspi Pay.
> 🔒 Все эндпоинты требуют [заголовки сессии](#заголовки-сессии).
### `POST /api/qr/create`
Создание QR-токена для оплаты.
**Тело запроса:**
| Поле | Тип | Обязательный | Описание |
|---|---|---|---|
| `amount` | `number` | ✅ | Сумма в тенге |
| `latitude` | `number` | ❌ | Широта (по умолчанию: Алматы) |
| `longitude` | `number` | ❌ | Долгота (по умолчанию: Алматы) |
**Пример запроса:**
```bash
curl -X POST http://localhost:3000/api/qr/create \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-H "X-Profile-Id: ..." \
-d '{"amount": 500}'
```
**Успешный ответ:**
```json
{
"StatusCode": 0,
"Data": {
"QrOperationId": 789012,
"QrToken": "https://pay.kaspi.kz/pay/...",
"ExpireDate": "2025-01-01T12:05:00",
"Amount": 500,
"ReceiptUrl": "https://..."
}
}
```
> 💡 `QrToken` содержит ссылку для оплаты — можно преобразовать в QR-код.
---
### `GET /api/qr/status`
Проверка статуса QR-платежа.
**Query-параметры:**
| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
| `qrOperationId` | `string` | ✅ | ID QR-операции из `/api/qr/create` |
**Пример запроса:**
```bash
curl "http://localhost:3000/api/qr/status?qrOperationId=789012" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..."
```
---
## History — История операций
Просмотр истории всех операций (QR + счета).
> 🔒 Все эндпоинты требуют [заголовки сессии](#заголовки-сессии).
### `POST /api/history/operations`
Получение списка операций за период.
**Тело запроса:**
| Поле | Тип | Обязательный | Описание |
|---|---|---|---|
| `endDate` | `string` | ✅ | Конечная дата (формат: `YYYY-MM-DD`) |
| `lastTransactionDate` | `string` | ❌ | Дата последней транзакции (для пагинации) |
| `statementPeriodCode` | `number` | ❌ | Код периода (по умолчанию: `0`) |
**Пример запроса:**
```bash
curl -X POST http://localhost:3000/api/history/operations \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-d '{"endDate": "2025-01-15"}'
```
---
### `POST /api/history/details`
Получение деталей конкретной операции.
**Тело запроса:**
| Поле | Тип | Обязательный | Описание |
|---|---|---|---|
| `id` | `number` | ✅ | ID операции |
| `operationMethod` | `number` | ❌ | Метод операции (по умолчанию: `0`) |
**Пример запроса:**
```bash
curl -X POST http://localhost:3000/api/history/details \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-d '{"id": 123456}'
```
---
## Refund — Возвраты
Возврат средств по ранее проведённой операции.
> 🔒 Все эндпоинты требуют [заголовки сессии](#заголовки-сессии).
### `POST /api/refund/create`
Создание возврата.
**Тело запроса:**
| Поле | Тип | Обязательный | Описание |
|---|---|---|---|
| `qrOperationId` | `number` | ✅ | ID операции для возврата |
| `returnAmount` | `number` | ✅ | Сумма возврата в тенге |
**Пример запроса:**
```bash
curl -X POST http://localhost:3000/api/refund/create \
-H "Content-Type: application/json" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..." \
-d '{"qrOperationId": 789012, "returnAmount": 500}'
```
---
## Session — Проверка сессии
### `GET /api/session/check`
Проверка валидности текущей сессии через запрос к Kaspi API.
> 🔒 Требует [заголовки сессии](#заголовки-сессии).
**Пример запроса:**
```bash
curl "http://localhost:3000/api/session/check" \
-H "X-Token-SN: ..." \
-H "X-Vtoken-Secret: ..."
```
**Активная сессия:**
```json
{ "active": true }
```
**Неактивная сессия:**
```json
{
"active": false,
"error": "Session rejected by Kaspi API.",
"code": 401,
"details": { ... }
}
```
---
## Коды ошибок
Все эндпоинты возвращают ошибки в формате:
```json
{ "error": "Описание ошибки" }
```
| HTTP-код | Описание |
|---|---|
| `400` | Отсутствуют обязательные параметры |
| `401` | Отсутствуют или невалидные заголовки сессии |
| `500` | Внутренняя ошибка сервера или ошибка Kaspi API |
---
## Webhooks — Уведомления
Система автоматически отслеживает статусы созданных QR- и invoice-платежей (polling каждые 3 секунды) и отправляет HTTP POST-уведомления (webhooks) на указанные URL при изменении статуса платежа.
### Настройка
Вебхуки настраиваются в файле `webhooks.json` в корне проекта. Файл содержит массив объектов:
```json
[
{
"url": "https://example.com/webhook",
"events": ["payment.success", "payment.failed", "payment.expired"],
"secret": "your-webhook-secret"
}
]
```
| Поле | Тип | Обязательный | Описание |
|---|---|---|---|
| `url` | `string` | ✅ | URL, на который будут отправляться уведомления |
| `events` | `string[]` | ✅ | Список событий для подписки |
| `secret` | `string` | ❌ | Секрет для HMAC-подписи (рекомендуется) |
> 💡 Для начала скопируйте `webhooks.example.json``webhooks.json` и отредактируйте.
Можно указать несколько вебхуков с разными URL и событиями:
```json
[
{
"url": "https://my-crm.com/kaspi-hook",
"events": ["payment.success"],
"secret": "crm-secret-key"
},
{
"url": "https://my-accounting.com/hook",
"events": ["payment.success", "payment.failed", "payment.expired"],
"secret": "accounting-secret"
}
]
```
### События
| Событие | Описание | Когда срабатывает |
|---|---|---|
| `payment.success` | Платёж успешно проведён | QR: статус `Processed`; Invoice: статус `Processed` |
| `payment.failed` | Платёж отклонён / отменён | QR: `CancelledByUser`, `Rejected`, `Error` и др.; Invoice: `RemotePaymentCanceled`, `RemotePaymentRejected` |
| `payment.expired` | Время оплаты истекло | QR: `QrTokenDiscarded`, `Expired`; Invoice: `Expired` |
### Формат payload
При срабатывании события на каждый подписанный URL отправляется POST-запрос с JSON-телом:
```json
{
"event": "payment.success",
"paymentId": "123456",
"type": "qr",
"status": "Processed",
"statusDesc": "Операция проведена успешно",
"amount": 5000,
"qrToken": "QR-TOKEN-...",
"receiptUrl": "https://...",
"orderNumber": "ORDER-001",
"data": { ... },
"timestamp": "2026-05-10T00:00:00.000Z"
}
```
| Поле | Тип | Описание |
|---|---|---|
| `event` | `string` | Название события (`payment.success`, `payment.failed`, `payment.expired`) |
| `paymentId` | `string` | ID платежа (QR operationId или invoice operationId) |
| `type` | `string` | Тип платежа: `qr` или `invoice` |
| `status` | `string` | Финальный статус от Kaspi API |
| `statusDesc` | `string` | Описание статуса |
| `amount` | `number\|null` | Сумма платежа в тенге |
| `qrToken` | `string\|null` | QR-токен (только для QR-платежей) |
| `receiptUrl` | `string\|null` | Ссылка на чек |
| `orderNumber` | `string\|null` | Номер заказа |
| `data` | `object` | Полные данные ответа от Kaspi API |
| `timestamp` | `string` | Время отправки уведомления (ISO 8601) |
### Подпись (HMAC)
Каждый запрос подписывается HMAC SHA-256 с использованием `secret` из конфигурации вебхука. Подпись передаётся в заголовке:
```
X-Webhook-Signature: sha256=<hex-digest>
```
**Проверка подписи на стороне получателя (Node.js):**
```javascript
import crypto from 'crypto';
const verifySignature = (body, signature, secret) => {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
};
// В обработчике запроса:
const rawBody = JSON.stringify(req.body); // или используйте raw body
const sig = req.headers['x-webhook-signature'];
if (!verifySignature(rawBody, sig, 'your-webhook-secret')) {
return res.status(401).send('Invalid signature');
}
```
### Повторные попытки (Retry)
Если доставка вебхука не удалась (ошибка сети, таймаут, HTTP-ошибка), система выполняет до **3 попыток** с нарастающей задержкой:
| Попытка | Задержка |
|---|---|
| 1-я (первая) | Немедленно |
| 2-я | 5 секунд |
| 3-я | 30 секунд |
- Таймаут запроса: **10 секунд**.
- Очередь повторных попыток сохраняется в `webhook-retries.json` и переживает перезапуск сервера.
- После 3 неудачных попыток уведомление отбрасывается (логируется ошибка).
---
## Типичный сценарий использования
```
1. POST /api/auth/init → получить processId
2. POST /api/auth/send-phone → отправить SMS
3. POST /api/auth/verify-otp → подтвердить код → получить tokenSN + vtokenSecret
4. POST /api/qr/create → создать QR для оплаты
5. GET /api/qr/status → проверить статус оплаты
— или —
4. POST /api/invoice/create → выставить счёт по номеру телефона
5. GET /api/invoice/details → проверить статус счёта
6. POST /api/refund/create → возврат средств (при необходимости)
```

30
eslint.config.js Normal file
View File

@ -0,0 +1,30 @@
import js from '@eslint/js';
export default [
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
console: 'readonly',
process: 'readonly',
Buffer: 'readonly',
URL: 'readonly',
setTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
clearTimeout: 'readonly',
fetch: 'readonly',
AbortController: 'readonly',
},
},
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-console': 'off',
},
},
{
ignores: ['node_modules/', 'logs/', 'public/'],
},
];

1
kaspi-pos-automation Submodule

@ -0,0 +1 @@
Subproject commit cc9b393ca0e3a040a359997082ebcc64b3bb2099

1847
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "kaspi-pos-automation",
"version": "1.0.0",
"type": "module",
"description": "Автоматизация платежей для POS-систем через Kaspi Pay API",
"main": "server.js",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tapter-dev/kaspi-pos-automation.git"
},
"bugs": {
"url": "https://github.com/tapter-dev/kaspi-pos-automation/issues"
},
"homepage": "https://github.com/tapter-dev/kaspi-pos-automation#readme",
"keywords": [
"kaspi",
"kaspi-pay",
"pos",
"payment",
"automation",
"qr-payment",
"invoice"
],
"engines": {
"node": ">=20.6.0"
},
"scripts": {
"start": "node server.js",
"test": "node --test test/**/*.test.js",
"lint": "eslint .",
"format": "prettier --write .",
"regen:keypair": "node scripts/regen-keypair.js",
"regen:device": "node scripts/regen-device.js"
},
"dependencies": {
"dotenv": "^17.3.1",
"express": "^4.18.2",
"node-fetch": "^2.7.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.3.0",
"prettier": "^3.8.3"
}
}

738
public/app.js Normal file
View File

@ -0,0 +1,738 @@
// ─── Kaspi Pay — Frontend App ───
const API = '';
// ─── State ───
let currentOpId = null;
let invoicePollingTimer = null;
let historyOpId = null;
let qrPollingTimer = null;
let qrCountdownTimer = null;
let qrOperationId = null;
// ─── Helpers ───
const $ = (id) => document.getElementById(id);
const digitsOnly = (str) => str.replace(/\D/g, '');
const getSession = () => {
try {
return JSON.parse(localStorage.getItem('kaspi_session') || '{}');
} catch {
return {};
}
};
const sessionHeaders = () => {
const s = getSession();
const h = {};
if (s.tokenSN) h['X-Token-SN'] = s.tokenSN;
if (s.profileId) h['X-Profile-ID'] = String(s.profileId);
if (s.vtokenSecret) h['X-Vtoken-Secret'] = s.vtokenSecret;
return h;
};
const apiFetch = async (path, opts = {}) => {
opts.headers = { ...sessionHeaders(), ...(opts.headers || {}) };
const resp = await fetch(API + path, opts);
return resp.json();
};
const apiPost = (path, body) =>
apiFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
...(body !== undefined && { body: JSON.stringify(body) }),
});
// ─── Session Persistence (localStorage) ───
const SESSION_KEY = 'kaspi_session';
const saveSession = (data) => {
let prev = {};
try {
prev = JSON.parse(localStorage.getItem(SESSION_KEY) || '{}');
} catch {}
const merged = { ...prev, ...data };
if (data.phone) merged.phoneNumber = data.phone;
localStorage.setItem(SESSION_KEY, JSON.stringify(merged));
};
const clearSession = () => localStorage.removeItem(SESSION_KEY);
const checkSession = async () => {
try {
const resp = await apiFetch('/api/session/check');
if (resp.active === true) return { active: true };
return { active: false, error: resp.error || 'Сессия неактивна' };
} catch {
return { active: false, error: 'Ошибка проверки сессии' };
}
};
const tryRestoreSession = async () => {
const session = getSession();
if (session.tokenSN && session.vtokenSecret) {
showMainScreen(session);
// Verify session is still active on the server
const result = await checkSession();
if (!result.active) {
clearSession();
$('mainScreen').classList.add('hidden');
$('authScreen').classList.remove('hidden');
setAuthStep(1);
showAuthMsg(result.error || 'Сессия истекла. Войдите заново.', 'err');
return false;
}
return true;
}
clearSession();
return false;
};
const updateRefreshAuthBtn = () => {
const session = getSession();
const btn = $('btnRefreshAuth');
if (btn) btn.classList.toggle('hidden', !session.tokenSN || !session.vtokenSecret);
};
const formatPhone = (digits) => {
// Format up to 10 digits as "XXX XXX XX XX"
const d = digits.slice(0, 10);
if (d.length <= 3) return d;
if (d.length <= 6) return `${d.slice(0, 3)} ${d.slice(3)}`;
if (d.length <= 8) return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6)}`;
return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6, 8)} ${d.slice(8)}`;
};
const attachPhoneFormatter = (el) => {
el.addEventListener('input', () => {
const digits = digitsOnly(el.value);
const formatted = formatPhone(digits);
if (el.value !== formatted) el.value = formatted;
});
};
window.addEventListener('DOMContentLoaded', () => {
updateRefreshAuthBtn();
tryRestoreSession();
attachPhoneFormatter($('phoneInput'));
attachPhoneFormatter($('clientPhone'));
});
// ─── Auth UI Helpers ───
const setAuthStep = (n) => {
for (let i = 1; i <= 3; i++) {
$(`authStep${i}`).classList.toggle('hidden', i !== n);
$(`dot${i}`).className = `step-dot${i < n ? ' done' : i === n ? ' active' : ''}`;
}
};
const showAuthMsg = (msg, type) => {
const el = $('authMsg');
if (!msg) {
el.classList.add('hidden');
return;
}
el.className = `status-bar status-${type}`;
el.textContent = msg;
el.classList.remove('hidden');
};
const resetAuth = () => {
setAuthStep(1);
$('otpInput').value = '';
showAuthMsg('', '');
};
// ─── Auth Flow ───
let authProcessId = null;
const sendPhone = async () => {
const phone = digitsOnly($('phoneInput').value);
if (phone.length < 10) return showAuthMsg('Введите 10 цифр номера', 'err');
const btn = $('btnSendPhone');
btn.disabled = true;
btn.innerHTML = 'Отправка...<span class="loader"></span>';
showAuthMsg('', '');
try {
const init = await apiPost('/api/auth/init');
if (!init.success) {
showAuthMsg(`Ошибка инициализации: ${JSON.stringify(init.body)}`, 'err');
return;
}
authProcessId = init.processId;
const resp = await apiPost('/api/auth/send-phone', { phoneNumber: phone, processId: authProcessId });
if (resp.success) {
$('otpDesc').textContent = resp.desc || `SMS отправлен на +7${phone}`;
setAuthStep(2);
} else {
showAuthMsg(`Ошибка: ${resp.body?.data?.desc || JSON.stringify(resp.body)}`, 'err');
}
} catch (e) {
showAuthMsg(`Ошибка сети: ${e.message}`, 'err');
} finally {
btn.disabled = false;
btn.textContent = 'Получить SMS код';
}
};
const verifyOtp = async () => {
const otp = digitsOnly($('otpInput').value);
if (!otp) return showAuthMsg('Введите код', 'err');
const btn = $('btnVerifyOtp');
btn.disabled = true;
btn.innerHTML = 'Проверка...<span class="loader"></span>';
showAuthMsg('', '');
try {
const resp = await apiPost('/api/auth/verify-otp', { otp, processId: authProcessId });
if (resp.success && resp.step === 'finished') {
saveSession(resp);
authProcessId = null;
showMainScreen(resp);
} else {
showAuthMsg(`Неверный код или ошибка: ${resp.body?.data?.desc || JSON.stringify(resp.body)}`, 'err');
}
} catch (e) {
showAuthMsg(`Ошибка: ${e.message}`, 'err');
} finally {
btn.disabled = false;
btn.textContent = 'Подтвердить';
}
};
// ─── Main Screen ───
const showMainScreen = (data) => {
$('authScreen').classList.add('hidden');
$('mainScreen').classList.remove('hidden');
if (data) {
$('userName').textContent = data.phone || '—';
$('userOrg').textContent = data.orgName || '—';
$('userAvatar').textContent = (data.orgName || 'K')[0].toUpperCase();
}
};
const logout = async () => {
const { tokenSN } = getSession();
clearSession();
await apiPost('/api/auth/logout', { tokenSN });
$('mainScreen').classList.add('hidden');
$('authScreen').classList.remove('hidden');
$('phoneInput').value = '';
$('otpInput').value = '';
setAuthStep(1);
showAuthMsg('', '');
};
const switchTab = (tab) => {
$('invoiceTab').classList.toggle('hidden', tab !== 'invoice');
$('qrTab').classList.toggle('hidden', tab !== 'qr');
$('historyTab').classList.toggle('hidden', tab !== 'history');
$('salesTab').classList.toggle('hidden', tab !== 'sales');
$('tabInvoice').classList.toggle('active', tab === 'invoice');
$('tabQr').classList.toggle('active', tab === 'qr');
$('tabHistory').classList.toggle('active', tab === 'history');
$('tabSales').classList.toggle('active', tab === 'sales');
if (tab === 'history') loadHistory();
if (tab === 'sales') loadSales();
};
// ─── Invoice ───
const statusBadge = (status) => {
const map = {
RemotePaymentCreated: ['Ожидает оплаты', 'pending'],
RemotePaymentPaid: ['Оплачен', 'paid'],
RemotePaymentCanceled: ['Отменён', 'canceled'],
RemotePaymentExpired: ['Истёк', 'expired'],
};
const [label, cls] = map[status] || [status, 'pending'];
return `<span class="badge badge-${cls}">${label}</span>`;
};
const renderDetails = (data, containerId) => {
if (!data) return;
const el = $(containerId);
const rows = (data.DynamicDetails || [])
.sort((a, b) => a.Order - b.Order)
.map(
({ Title, Data, IsBold }) =>
`<div class="detail-row">
<span class="detail-label">${Title}</span>
<span class="detail-value" style="${IsBold ? 'font-weight:700' : ''}">${Data}</span>
</div>`,
)
.join('');
el.innerHTML = `<div style="text-align:center;margin:12px 0;">${statusBadge(data.Status)}</div>${rows}`;
};
const stopInvoicePolling = () => {
if (invoicePollingTimer) {
clearInterval(invoicePollingTimer);
invoicePollingTimer = null;
}
};
const refreshInvoice = async () => {
if (!currentOpId) return null;
try {
const resp = await apiFetch(`/api/invoice/details?operationId=${currentOpId}`);
renderDetails(resp.Data, 'invoiceDetails');
const { Status: status } = resp.Data || {};
$('btnCancel').classList.toggle('hidden', status !== 'RemotePaymentCreated');
if (status && status !== 'RemotePaymentCreated') stopInvoicePolling();
return status;
} catch (e) {
console.error(e);
return null;
}
};
const startInvoicePolling = () => {
stopInvoicePolling();
refreshInvoice();
invoicePollingTimer = setInterval(refreshInvoice, 5000);
};
const createInvoice = async () => {
const phone = digitsOnly($('clientPhone').value);
const amount = $('invoiceAmount').value;
const comment = $('invoiceComment').value || 'Оплата';
if (!phone || !amount) return alert('Заполните телефон и сумму');
const btn = $('btnCreate');
btn.disabled = true;
btn.innerHTML = 'Создание...<span class="loader"></span>';
try {
const resp = await apiPost('/api/invoice/create', {
phoneNumber: phone,
amount: Number(amount),
comment,
});
if (resp.Data?.QrOperationId) {
currentOpId = resp.Data.QrOperationId;
$('invoiceOpId').textContent = `#${currentOpId}`;
$('invoiceResult').classList.remove('hidden');
$('clientPhone').value = '';
$('invoiceAmount').value = '';
$('invoiceComment').value = '';
$('clientInfo').classList.add('hidden');
startInvoicePolling();
} else {
alert(`Ошибка: ${resp.Message || JSON.stringify(resp)}`);
}
} catch (e) {
alert(`Ошибка: ${e.message}`);
} finally {
btn.disabled = false;
btn.textContent = 'Выставить счёт';
}
};
const cancelInvoice = async () => {
if (!currentOpId || !confirm('Отменить счёт?')) return;
try {
await apiPost('/api/invoice/cancel', { operationId: currentOpId });
refreshInvoice();
} catch (e) {
alert(`Ошибка: ${e.message}`);
}
};
// ─── QR Code ───
const FINAL_QR_STATUSES = [
'Paid',
'CancelledByUser',
'NotConfirmedByUser',
'QrTokenDiscarded',
'ProcessingFailed',
'InsufficientFunds',
'Error',
];
const qrStatusBadge = (status) => {
const map = {
QrTokenCreated: ['Ожидание сканирования', 'info'],
QrTokenScanned: ['Отсканирован', 'info'],
PaymentConfirmation: ['Подтверждение оплаты...', 'warn'],
Paid: ['Оплачено ✅', 'ok'],
CancelledByUser: ['Отменено клиентом', 'err'],
NotConfirmedByUser: ['Не подтверждено', 'err'],
QrTokenDiscarded: ['QR не отсканирован', 'err'],
ProcessingFailed: ['Ошибка обработки', 'err'],
InsufficientFunds: ['Недостаточно средств', 'err'],
Error: ['Ошибка', 'err'],
};
const [label, cls] = map[status] || [status, 'info'];
return { label, cls };
};
const stopQrPolling = () => {
if (qrPollingTimer) {
clearInterval(qrPollingTimer);
qrPollingTimer = null;
}
if (qrCountdownTimer) {
clearInterval(qrCountdownTimer);
qrCountdownTimer = null;
}
};
const pollQrStatus = async () => {
if (!qrOperationId) return;
try {
const resp = await apiFetch(`/api/qr/status?qrOperationId=${qrOperationId}`);
const status = resp.Data?.Status;
if (status) {
const { label, cls } = qrStatusBadge(status);
const el = $('qrStatus');
el.className = `status-bar status-${cls}`;
el.textContent = label;
}
if (FINAL_QR_STATUSES.includes(status)) {
stopQrPolling();
}
} catch (e) {
console.error('QR polling error:', e);
}
};
const startQrCountdown = (seconds) => {
let remaining = seconds;
const timerEl = $('qrTimer');
const tick = () => {
const m = Math.floor(remaining / 60);
const s = remaining % 60;
timerEl.textContent = `Осталось: ${m}:${String(s).padStart(2, '0')}`;
if (remaining <= 0) {
timerEl.textContent = 'Время истекло';
stopQrPolling();
}
remaining--;
};
tick();
qrCountdownTimer = setInterval(tick, 1000);
};
const generateQrSvg = (text, size = 256) => {
// Simple QR placeholder using a data URL image via an API
// For production, use a proper QR library; here we use a public API fallback
return `<img src="https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encodeURIComponent(text)}" alt="QR Code" style="max-width:100%;border-radius:8px;">`;
};
const createQr = async () => {
const amount = $('qrAmount').value;
if (!amount) return alert('Введите сумму');
const btn = $('btnCreateQr');
btn.disabled = true;
btn.innerHTML = 'Создание...<span class="loader"></span>';
stopQrPolling();
try {
const resp = await apiPost('/api/qr/create', { amount: Number(amount) });
if (resp.Data?.QrToken) {
qrOperationId = resp.Data.QrOperationId;
const options = resp.Data.QrPaymentBehaviorOptions || {};
const pollInterval = (parseInt(options.qrCodeScanEventPollingInterval) || 3) * 1000;
const waitTimeout = parseInt(options.qrCodeScanWaitTimeout) || 180;
$('qrCodeContainer').innerHTML = generateQrSvg(resp.Data.QrToken);
$('qrStatus').className = 'status-bar status-info';
$('qrStatus').textContent = 'Ожидание сканирования...';
$('qrResult').classList.remove('hidden');
$('qrAmount').value = '';
startQrCountdown(waitTimeout);
qrPollingTimer = setInterval(pollQrStatus, pollInterval);
} else {
alert(`Ошибка: ${resp.Message || JSON.stringify(resp)}`);
}
} catch (e) {
alert(`Ошибка: ${e.message}`);
} finally {
btn.disabled = false;
btn.textContent = 'Создать QR';
}
};
const resetQr = () => {
stopQrPolling();
qrOperationId = null;
$('qrResult').classList.add('hidden');
$('qrCodeContainer').innerHTML = '';
$('qrTimer').textContent = '';
};
// ─── Client Phone Lookup ───
$('clientPhone').addEventListener('blur', async function () {
const phone = digitsOnly(this.value);
const info = $('clientInfo');
if (phone.length < 10) {
info.classList.add('hidden');
return;
}
try {
const resp = await apiFetch(`/api/invoice/client-info?phoneNumber=${phone}`);
if (resp.Data?.ClientName) {
info.textContent = `${resp.Data.ClientName} (${resp.Data.ClientStatus})`;
info.style.background = '#e8f5e9';
info.style.color = '#2e7d32';
info.classList.remove('hidden');
} else {
info.textContent = '✗ Клиент не найден';
info.style.background = '#ffebee';
info.style.color = '#c62828';
info.classList.remove('hidden');
}
} catch {
info.classList.add('hidden');
}
});
// ─── History ───
const loadHistory = async () => {
const list = $('historyList');
list.innerHTML =
'<p style="text-align:center;color:#888;">Загрузка...<span class="loader" style="border-color:#888;border-top-color:transparent;"></span></p>';
try {
const resp = await apiPost('/api/invoice/history');
const ops = resp.Data?.Operations || [];
if (!ops.length) {
list.innerHTML = '<p style="text-align:center;color:#888;">Нет операций</p>';
return;
}
list.innerHTML = ops
.map(
(op) => `
<div class="op-item" onclick="showHistoryDetail(${op.Id})">
<div class="op-row">
<div>
<div class="op-name">${op.ClientName || op.ClientShortName || '—'}</div>
<div class="op-date">${new Date(op.OrderRegDate).toLocaleString('ru')}</div>
</div>
<div style="text-align:right;">
<div class="op-amount">${op.Amount}</div>
${statusBadge(op.Status)}
</div>
</div>
</div>`,
)
.join('');
} catch (e) {
list.innerHTML = `<p style="color:#c62828;">Ошибка: ${e.message}</p>`;
}
};
const showHistoryDetail = async (opId) => {
historyOpId = opId;
const panel = $('historyDetail');
const content = $('historyDetailContent');
content.innerHTML = '<p style="text-align:center;">Загрузка...</p>';
panel.classList.remove('hidden');
try {
const resp = await apiFetch(`/api/invoice/details?operationId=${opId}`);
renderDetails(resp.Data, 'historyDetailContent');
$('btnCancelFromHistory').classList.toggle('hidden', resp.Data?.Status !== 'RemotePaymentCreated');
} catch {
content.innerHTML = '<p style="color:#c62828;">Ошибка</p>';
}
};
const cancelFromHistory = async () => {
if (!historyOpId || !confirm('Отменить счёт?')) return;
try {
await apiPost('/api/invoice/cancel', { operationId: historyOpId });
showHistoryDetail(historyOpId);
loadHistory();
} catch (e) {
alert(`Ошибка: ${e.message}`);
}
};
// ─── Sales (operations history + details + refund) ───
let salesOpId = null;
const loadSales = async () => {
const list = $('salesList');
const stats = $('salesStats');
list.innerHTML =
'<p style="text-align:center;color:#888;">Загрузка...<span class="loader" style="border-color:#888;border-top-color:transparent;"></span></p>';
stats.innerHTML = '';
try {
const now = new Date();
const endDate =
now.getFullYear() +
'-' +
String(now.getMonth() + 1).padStart(2, '0') +
'-' +
String(now.getDate()).padStart(2, '0') +
'T23:59:59.000+0500';
const resp = await apiPost('/api/history/operations', { endDate });
const data = resp.Data || {};
// Stats
const s = data.Statistic || {};
stats.innerHTML = `<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;">
<div style="background:#e8f5e9;padding:8px 14px;border-radius:10px;text-align:center;"><div style="font-size:12px;color:#2e7d32;">Продажи</div><div style="font-weight:700;color:#2e7d32;">${s.SalesAmount ?? 0} </div><div style="font-size:11px;color:#888;">${s.SalesCount ?? 0} шт</div></div>
<div style="background:#ffebee;padding:8px 14px;border-radius:10px;text-align:center;"><div style="font-size:12px;color:#c62828;">Возвраты</div><div style="font-weight:700;color:#c62828;">${s.ReturnsAmount ?? 0} </div><div style="font-size:11px;color:#888;">${s.ReturnsCount ?? 0} шт</div></div>
</div>`;
// Operations list
const dailySets = data.DailySets || [];
if (!dailySets.length) {
list.innerHTML = '<p style="text-align:center;color:#888;">Нет операций</p>';
return;
}
let html = '';
for (const day of dailySets) {
html += `<div style="font-size:13px;color:#888;margin:12px 0 4px;font-weight:600;">${day.Date || ''}</div>`;
for (const op of day.Operations || []) {
const isReturn = op.OperationType === 1;
const sign = isReturn ? '+' : '';
const color = isReturn ? '#c62828' : '#1a1a1a';
html += `<div class="op-item" onclick="showSalesDetail(${op.Id}, ${op.OperationMethod || 0})">
<div class="op-row">
<div>
<div class="op-name">${op.ClientName || op.ClientShortName || '—'}</div>
<div class="op-date">${op.Time || ''}</div>
</div>
<div style="text-align:right;">
<div class="op-amount" style="color:${color};">${sign}${op.Amount} </div>
</div>
</div>
</div>`;
}
}
list.innerHTML = html;
} catch (e) {
list.innerHTML = `<p style="color:#c62828;">Ошибка: ${e.message}</p>`;
}
};
const showSalesDetail = async (id, operationMethod) => {
salesOpId = id;
const panel = $('salesDetail');
const content = $('salesDetailContent');
const refundSection = $('refundSection');
const refundMsg = $('refundMsg');
content.innerHTML = '<p style="text-align:center;">Загрузка...</p>';
refundSection.classList.add('hidden');
refundMsg.classList.add('hidden');
panel.classList.remove('hidden');
try {
const resp = await apiPost('/api/history/details', { id, operationMethod: operationMethod || 0 });
const d = resp.Data || {};
let rows = '';
const fields = [
['Сумма', d.Amount ? `${d.Amount}` : null],
['Клиент', d.ClientName || d.ClientShortName],
['Дата', d.OrderRegDate ? new Date(d.OrderRegDate).toLocaleString('ru') : null],
['Статус', d.StatusDescription],
['Доступно к возврату', d.AvailableReturnAmount != null ? `${d.AvailableReturnAmount}` : null],
['Тип возврата', d.PossibleReturnType],
['Чек', d.ReceiptUrl ? `<a href="${d.ReceiptUrl}" target="_blank">Открыть</a>` : null],
];
for (const [label, value] of fields) {
if (value != null)
rows += `<div class="detail-row"><span class="detail-label">${label}</span><span class="detail-value">${value}</span></div>`;
}
// Returns history
if (d.Returns && d.Returns.length) {
rows += '<div style="margin-top:12px;font-weight:600;font-size:14px;">Возвраты:</div>';
for (const r of d.Returns) {
rows += `<div class="detail-row"><span class="detail-label">${r.Date || ''}</span><span class="detail-value" style="color:#c62828;">${r.Amount} ₸</span></div>`;
}
}
content.innerHTML = rows || '<p style="color:#888;">Нет данных</p>';
// Show refund if available
const returnAmount = parseFloat(String(d.AvailableReturnAmount || '0').replace(/[^\d.]/g, ''));
if (returnAmount > 0) {
$('refundAmount').value = returnAmount;
$('refundAmount').max = returnAmount;
refundSection.classList.remove('hidden');
}
} catch (e) {
content.innerHTML = `<p style="color:#c62828;">Ошибка: ${e.message}</p>`;
}
};
const createRefund = async () => {
const amount = $('refundAmount').value;
if (!salesOpId || !amount) return alert('Укажите сумму возврата');
if (!confirm(`Вернуть ${amount} ₸?`)) return;
const btn = $('btnRefund');
const msg = $('refundMsg');
btn.disabled = true;
btn.innerHTML = 'Возврат...<span class="loader"></span>';
msg.classList.add('hidden');
try {
const resp = await apiPost('/api/refund/create', { qrOperationId: salesOpId, returnAmount: Number(amount) });
if (resp.StatusCode === 0) {
msg.className = 'status-bar status-ok';
msg.textContent = 'Возврат выполнен успешно';
} else {
msg.className = 'status-bar status-err';
msg.textContent = resp.Description || resp.Message || 'Ошибка возврата';
}
msg.classList.remove('hidden');
// Refresh detail
showSalesDetail(salesOpId, 0);
} catch (e) {
msg.className = 'status-bar status-err';
msg.textContent = `Ошибка: ${e.message}`;
msg.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'Сделать возврат';
}
};
// ─── Expose to HTML onclick handlers ───
Object.assign(window, {
sendPhone,
verifyOtp,
resetAuth,
logout,
switchTab,
createInvoice,
refreshInvoice,
cancelInvoice,
createQr,
resetQr,
loadHistory,
showHistoryDetail,
cancelFromHistory,
loadSales,
showSalesDetail,
createRefund,
});
// ─── Init ───
(() => {
setAuthStep(1);
const session = getSession();
if (session.tokenSN && session.vtokenSecret) {
showMainScreen(session);
}
})();

483
public/index.html Normal file
View File

@ -0,0 +1,483 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kaspi Pay</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #1a1a1a;
}
.container {
max-width: 480px;
margin: 0 auto;
padding: 16px;
}
h1 {
text-align: center;
color: #e31e24;
font-size: 24px;
margin: 16px 0;
}
.card {
background: #fff;
border-radius: 16px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.card h2 {
font-size: 18px;
margin-bottom: 12px;
color: #333;
}
label {
display: block;
font-size: 13px;
color: #888;
margin-bottom: 4px;
margin-top: 12px;
}
input,
select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 10px;
font-size: 16px;
outline: none;
}
input:focus {
border-color: #e31e24;
}
.btn {
width: 100%;
padding: 14px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 12px;
}
.btn-primary {
background: #e31e24;
color: #fff;
}
.btn-primary:disabled {
background: #ccc;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.btn-danger {
background: #ff3b30;
color: #fff;
}
.status-bar {
padding: 10px 16px;
border-radius: 10px;
margin-bottom: 12px;
font-size: 14px;
text-align: center;
}
.status-ok {
background: #e8f5e9;
color: #2e7d32;
}
.status-err {
background: #ffebee;
color: #c62828;
}
.status-info {
background: #e3f2fd;
color: #1565c0;
}
.status-warn {
background: #fff3e0;
color: #e65100;
}
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
}
.badge-pending {
background: #fff3e0;
color: #e65100;
}
.badge-paid {
background: #e8f5e9;
color: #2e7d32;
}
.badge-canceled {
background: #ffebee;
color: #c62828;
}
.badge-expired {
background: #f5f5f5;
color: #888;
}
.hidden {
display: none;
}
.otp-input {
letter-spacing: 12px;
text-align: center;
font-size: 24px;
font-weight: 700;
}
.phone-prefix {
display: flex;
align-items: center;
gap: 8px;
}
.phone-prefix span {
font-size: 16px;
font-weight: 600;
color: #333;
white-space: nowrap;
}
.phone-prefix input {
flex: 1;
}
.step-indicator {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 16px;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #ddd;
}
.step-dot.active {
background: #e31e24;
}
.step-dot.done {
background: #4caf50;
}
.op-item {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
}
.op-item:last-child {
border-bottom: none;
}
.op-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.op-name {
font-weight: 500;
}
.op-amount {
font-weight: 700;
}
.op-date {
font-size: 12px;
color: #888;
margin-top: 2px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
}
.detail-label {
color: #888;
font-size: 14px;
}
.detail-value {
font-weight: 500;
font-size: 14px;
text-align: right;
}
.loader {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid #fff;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
vertical-align: middle;
margin-left: 8px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.tabs {
display: flex;
gap: 0;
margin-bottom: 16px;
border-radius: 12px;
overflow: hidden;
border: 1px solid #ddd;
}
.tab {
flex: 1;
padding: 10px;
text-align: center;
font-size: 14px;
font-weight: 600;
cursor: pointer;
background: #fff;
border: none;
}
.tab.active {
background: #e31e24;
color: #fff;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: #e31e24;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
}
.user-details {
flex: 1;
}
.user-name {
font-weight: 600;
font-size: 16px;
}
.user-org {
font-size: 13px;
color: #888;
}
</style>
</head>
<body>
<div class="container">
<h1>🏦 Kaspi Pay</h1>
<!-- ═══ AUTH SCREEN ═══ -->
<div id="authScreen">
<div class="card">
<div class="step-indicator">
<div class="step-dot" id="dot1"></div>
<div class="step-dot" id="dot2"></div>
<div class="step-dot" id="dot3"></div>
</div>
<!-- Step 1: Phone -->
<div id="authStep1">
<h2>Вход по номеру телефона</h2>
<p style="font-size: 14px; color: #666; margin-bottom: 12px">Войдите с аккаунта кассира Kaspi Pay</p>
<label>Номер телефона</label>
<div class="phone-prefix">
<span>+7</span>
<input type="tel" id="phoneInput" placeholder="XXX XXX XX XX" inputmode="numeric" />
</div>
<button class="btn btn-primary" id="btnSendPhone" onclick="sendPhone()">Получить SMS код</button>
</div>
<!-- Step 2: OTP -->
<div id="authStep2" class="hidden">
<h2>Введите код из SMS</h2>
<p id="otpDesc" style="font-size: 14px; color: #666; margin-bottom: 12px"></p>
<label>Код подтверждения</label>
<input type="tel" id="otpInput" class="otp-input" placeholder="• • • •" maxlength="6" inputmode="numeric" />
<button class="btn btn-primary" id="btnVerifyOtp" onclick="verifyOtp()">Подтвердить</button>
<button class="btn btn-secondary" onclick="resetAuth()">Назад</button>
</div>
<!-- Step 3: Finishing -->
<div id="authStep3" class="hidden">
<h2>Завершение входа...</h2>
<p style="text-align: center; color: #888; margin: 20px 0">
Подключение к Kaspi Pay<span
class="loader"
style="border-color: #e31e24; border-top-color: transparent"
></span>
</p>
</div>
<div id="authMsg" class="hidden" style="margin-top: 12px"></div>
</div>
</div>
<!-- ═══ MAIN SCREEN ═══ -->
<div id="mainScreen" class="hidden">
<!-- User info -->
<div class="card" id="userCard">
<div class="user-info">
<div class="user-avatar" id="userAvatar">K</div>
<div class="user-details">
<div class="user-name" id="userName"></div>
<div class="user-org" id="userOrg"></div>
</div>
<button
class="btn btn-secondary"
style="width: auto; margin: 0; padding: 8px 16px; font-size: 13px"
onclick="logout()"
>
Выйти
</button>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" id="tabInvoice" onclick="switchTab('invoice')">Счёт</button>
<button class="tab" id="tabQr" onclick="switchTab('qr')">Создать QR</button>
<button class="tab" id="tabHistory" onclick="switchTab('history')">История</button>
<button class="tab" id="tabSales" onclick="switchTab('sales')">Продажи</button>
</div>
<!-- Invoice Tab -->
<div id="invoiceTab">
<div class="card">
<h2>Выставить счёт</h2>
<label>Телефон клиента</label>
<div class="phone-prefix">
<span>+7</span>
<input type="tel" id="clientPhone" placeholder="XXX XXX XX XX" inputmode="numeric" />
</div>
<div
id="clientInfo"
class="hidden"
style="
margin-top: 8px;
padding: 8px 12px;
background: #e8f5e9;
border-radius: 8px;
font-size: 14px;
color: #2e7d32;
"
></div>
<label>Сумма (₸)</label>
<input type="number" id="invoiceAmount" placeholder="0" min="1" inputmode="numeric" />
<label>Комментарий</label>
<input type="text" id="invoiceComment" placeholder="Оплата" />
<button class="btn btn-primary" id="btnCreate" onclick="createInvoice()">Выставить счёт</button>
</div>
<!-- Invoice result -->
<div id="invoiceResult" class="hidden">
<div class="card">
<h2>Счёт <span id="invoiceOpId"></span></h2>
<div id="invoiceDetails"></div>
<button class="btn btn-danger" id="btnCancel" onclick="cancelInvoice()" style="margin-top: 12px">
Отменить счёт
</button>
<button class="btn btn-secondary" onclick="refreshInvoice()">Обновить статус</button>
</div>
</div>
</div>
<!-- QR Tab -->
<div id="qrTab" class="hidden">
<div class="card">
<h2>Создать QR для оплаты</h2>
<label>Сумма (₸)</label>
<input type="number" id="qrAmount" placeholder="0" min="1" inputmode="numeric" />
<button class="btn btn-primary" id="btnCreateQr" onclick="createQr()">Создать QR</button>
</div>
<!-- QR result -->
<div id="qrResult" class="hidden">
<div class="card">
<h2>QR-код для оплаты</h2>
<div id="qrStatus" class="status-bar status-info" style="margin-bottom: 12px">
Ожидание сканирования...
</div>
<div id="qrTimer" style="text-align: center; font-size: 14px; color: #888; margin-bottom: 12px"></div>
<div id="qrCodeContainer" style="text-align: center; margin: 16px 0"></div>
<div id="qrDetails"></div>
<button class="btn btn-secondary" onclick="resetQr()">Новый QR</button>
</div>
</div>
</div>
<!-- History Tab -->
<div id="historyTab" class="hidden">
<div class="card">
<h2>История операций</h2>
<button class="btn btn-secondary" onclick="loadHistory()" style="margin-bottom: 12px">Обновить</button>
<div id="historyList"><p style="color: #888; text-align: center">Нажмите «Обновить»</p></div>
</div>
<div id="historyDetail" class="hidden">
<div class="card">
<h2>Детали операции</h2>
<div id="historyDetailContent"></div>
<button class="btn btn-danger hidden" id="btnCancelFromHistory" onclick="cancelFromHistory()">
Отменить
</button>
<button
class="btn btn-secondary"
onclick="document.getElementById('historyDetail').classList.add('hidden')"
>
Закрыть
</button>
</div>
</div>
</div>
<!-- Sales Tab -->
<div id="salesTab" class="hidden">
<div class="card">
<h2>Продажи и возвраты</h2>
<div id="salesStats" style="margin-bottom: 12px"></div>
<button class="btn btn-secondary" onclick="loadSales()" style="margin-bottom: 12px">Обновить</button>
<div id="salesList"><p style="color: #888; text-align: center">Нажмите «Обновить»</p></div>
</div>
<div id="salesDetail" class="hidden">
<div class="card">
<h2>Детали операции</h2>
<div id="salesDetailContent"></div>
<div id="refundSection" class="hidden" style="margin-top: 12px">
<label>Сумма возврата (₸)</label>
<input type="number" id="refundAmount" placeholder="0" min="1" inputmode="numeric" />
<button class="btn btn-danger" id="btnRefund" onclick="createRefund()" style="margin-top: 8px">
Сделать возврат
</button>
</div>
<div id="refundMsg" class="hidden" style="margin-top: 8px"></div>
<button
class="btn btn-secondary"
onclick="document.getElementById('salesDetail').classList.add('hidden')"
style="margin-top: 8px"
>
Закрыть
</button>
</div>
</div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

25
scripts/regen-device.js Normal file
View File

@ -0,0 +1,25 @@
#!/usr/bin/env node
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const FILE = path.join(ROOT, 'device.json');
if (fs.existsSync(FILE)) {
fs.renameSync(FILE, `${FILE}.bak`);
console.log('Backed up old device.json → device.json.bak');
}
const device = {
deviceId: crypto.randomUUID().toUpperCase(),
installId: crypto.randomUUID().toUpperCase(),
pinHash: crypto.createHash('md5').update(crypto.randomBytes(16)).digest('hex'),
};
fs.writeFileSync(FILE, JSON.stringify(device, null, 2));
console.log('Generated new device identity → device.json');
console.log(` deviceId: ${device.deviceId}`);
console.log(` installId: ${device.installId}`);
console.log(` pinHash: ${device.pinHash}`);
console.log('⚠️ Re-authentication (SMS) required after device change.');

22
scripts/regen-keypair.js Normal file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env node
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const FILE = path.join(ROOT, 'keypair.json');
if (fs.existsSync(FILE)) {
fs.renameSync(FILE, `${FILE}.bak`);
console.log('Backed up old keypair.json → keypair.json.bak');
}
const keyPair = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const saved = {
privateKey: keyPair.privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64'),
publicKey: keyPair.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
};
fs.writeFileSync(FILE, JSON.stringify(saved, null, 2));
console.log('Generated new ECDSA keypair → keypair.json');
console.log('⚠️ Re-authentication (SMS) required after keypair change.');

30
server.js Normal file
View File

@ -0,0 +1,30 @@
import express from 'express';
import path from 'path';
import { PORT, ROOT_DIR } from './src/config.js';
import authRoutes from './src/routes/auth.js';
import invoiceRoutes from './src/routes/invoice.js';
import qrRoutes from './src/routes/qr.js';
import historyRoutes from './src/routes/history.js';
import refundRoutes from './src/routes/refund.js';
import sessionRoutes from './src/routes/session.js';
import { startPolling } from './src/polling.js';
import 'dotenv/config';
const app = express();
app.use(express.json());
app.use(express.static(path.join(ROOT_DIR, 'public')));
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use('/api/auth', authRoutes);
app.use('/api/invoice', invoiceRoutes);
app.use('/api/qr', qrRoutes);
app.use('/api/history', historyRoutes);
app.use('/api/refund', refundRoutes);
app.use('/api/session', sessionRoutes);
app.listen(PORT, () => {
console.log(`\n 🟢 Kaspi Pay App running at http://localhost:${PORT}\n`);
startPolling();
});

113
src/config.js Normal file
View File

@ -0,0 +1,113 @@
import 'dotenv/config';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, '..');
export const PORT = process.env.PORT || 3000;
// ─── ECDSA P-256 keypair (persisted to keypair.json) ───
const KEYPAIR_FILE = path.join(ROOT_DIR, 'keypair.json');
let ecKeyPair;
if (fs.existsSync(KEYPAIR_FILE)) {
const saved = JSON.parse(fs.readFileSync(KEYPAIR_FILE, 'utf8'));
ecKeyPair = {
privateKey: crypto.createPrivateKey({ key: Buffer.from(saved.privateKey, 'base64'), format: 'der', type: 'pkcs8' }),
publicKey: crypto.createPublicKey({ key: Buffer.from(saved.publicKey, 'base64'), format: 'der', type: 'spki' }),
};
console.log('Loaded ECDSA keypair from keypair.json');
} else {
ecKeyPair = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const saved = {
privateKey: ecKeyPair.privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64'),
publicKey: ecKeyPair.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
};
fs.writeFileSync(KEYPAIR_FILE, JSON.stringify(saved, null, 2));
console.log('Generated new ECDSA keypair → saved to keypair.json');
}
export { ecKeyPair };
// Uncompressed EC public key point (base64)
const pubKeyDer = ecKeyPair.publicKey.export({ type: 'spki', format: 'der' });
const x509B64 = pubKeyDer.toString('base64');
const uncompressedPoint = pubKeyDer.slice(pubKeyDer.length - 65);
const pkB64 = uncompressedPoint.toString('base64');
const pkTagHash = crypto.createHash('md5').update(pkB64).digest('hex');
// ─── Device identity (persisted to device.json) ───
const DEVICE_FILE = path.join(ROOT_DIR, 'device.json');
let deviceId, installId, pinHash;
if (fs.existsSync(DEVICE_FILE)) {
const saved = JSON.parse(fs.readFileSync(DEVICE_FILE, 'utf8'));
({ deviceId, installId, pinHash } = saved);
console.log('Loaded device identity from device.json');
} else {
deviceId = crypto.randomUUID().toUpperCase();
installId = crypto.randomUUID().toUpperCase();
pinHash = crypto.createHash('md5').update(crypto.randomBytes(16)).digest('hex');
fs.writeFileSync(DEVICE_FILE, JSON.stringify({ deviceId, installId, pinHash }, null, 2));
console.log('Generated new device identity → saved to device.json');
}
export const DEVICE = {
deviceId,
installId,
pk: pkB64,
pkTag: pkTagHash,
pinHash,
x509: x509B64,
};
console.log(' pk:', DEVICE.pk);
console.log(' x509:', DEVICE.x509);
console.log(' pkTag:', DEVICE.pkTag);
// ─── Kaspi Base URLs ───
export const KASPI_ENTRANCE_URL = 'https://entrance-pay.kaspi.kz';
export const KASPI_MTOKEN_URL = 'https://mtoken.kaspi.kz';
export const KASPI_QRPAY_URL = 'https://qrpay.kaspi.kz';
// ─── App version & device constants ───
// Defaults match a known-good Kaspi Pay client. Override via .env if needed.
// ⚠️ The Kaspi API validates these parameters and may reject unknown values.
export const APP = {
version: process.env.APP_VERSION || '4.112.1',
build: process.env.APP_BUILD || '1107',
platform: process.env.APP_PLATFORM || 'iOS',
platformVer: process.env.APP_PLATFORM_VER || '18.4',
locale: process.env.APP_LOCALE || 'ru-RU',
model: process.env.APP_MODEL || 'iPhone16,2',
brand: process.env.APP_BRAND || 'Apple',
deviceName: process.env.APP_DEVICE_NAME || 'iPhone',
screenW: process.env.APP_SCREEN_W || '430.0',
screenH: process.env.APP_SCREEN_H || '932.0',
cfNetwork: process.env.APP_CFNETWORK || 'CFNetwork/3826.400.120',
darwin: process.env.APP_DARWIN || 'Darwin/24.4.0',
};
export const UA_NATIVE = `Kaspi%20Pay/${APP.build} ${APP.cfNetwork} ${APP.darwin}`;
export const UA_BROWSER = `Mozilla/5.0 (iPhone; CPU iPhone OS ${APP.platformVer.replace('.', '_')} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148`;
export const ENTRANCE_HEADERS_BASE = {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'Accept-Language': 'ru',
'Accept-Encoding': 'gzip, deflate, br',
Origin: KASPI_ENTRANCE_URL,
'Sec-Fetch-Site': 'same-origin',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Dest': 'empty',
'User-Agent': UA_BROWSER,
};
export { ROOT_DIR };

161
src/crypto.js Normal file
View File

@ -0,0 +1,161 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import {fileURLToPath} from 'url';
import {ecKeyPair} from './config.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ECDH_FILE = path.join(__dirname, '..', 'ecdh-keypair.json');
// ─── ECDH ───
const vtokenSuite = 'OCRA-1:HOTP-SHA256-6:QH64-T1M';
// ─── AES-256-GCM encryption for vtokenSecret ───
if (!process.env.TOKEN_SECRET_KEY) {
console.error('FATAL: TOKEN_SECRET_KEY environment variable is not set.');
console.error('Generate one with: echo "TOKEN_SECRET_KEY=$(openssl rand -hex 32)" > .env');
process.exit(1);
}
const ENCRYPTION_KEY = Buffer.from(process.env.TOKEN_SECRET_KEY, 'hex');
export const encryptSecret = (secretBuffer) => {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
const encrypted = Buffer.concat([cipher.update(secretBuffer), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64');
};
export const decryptSecret = (tokenB64) => {
const buf = Buffer.from(tokenB64, 'base64');
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const encrypted = buf.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
};
let lastEcdhKeyPair = null;
export const generateECDH = () => {
lastEcdhKeyPair = crypto.generateKeyPairSync('ec', {namedCurve: 'prime256v1'});
// Persist ECDH private key so refresh (SignInLite) can reuse it
const saved = {
privateKey: lastEcdhKeyPair.privateKey.export({type: 'pkcs8', format: 'der'}).toString('base64'),
publicKey: lastEcdhKeyPair.publicKey.export({type: 'spki', format: 'der'}).toString('base64'),
};
fs.writeFileSync(ECDH_FILE, JSON.stringify(saved, null, 2));
const spki = lastEcdhKeyPair.publicKey.export({type: 'spki', format: 'der'});
return spki.toString('base64');
};
export const completeECDH = (serverX509B64) => {
if (!lastEcdhKeyPair) throw new Error('No ECDH keypair generated');
const serverPubKey = crypto.createPublicKey({
key: Buffer.from(serverX509B64, 'base64'),
format: 'der',
type: 'spki',
});
const secret = crypto.diffieHellman({
privateKey: lastEcdhKeyPair.privateKey,
publicKey: serverPubKey,
});
console.log('ECDH shared secret derived, length:', secret.length);
lastEcdhKeyPair = null;
return secret;
};
export const completeECDHWithSaved = (serverX509B64) => {
if (!fs.existsSync(ECDH_FILE)) throw new Error('No saved ECDH keypair (ecdh-keypair.json missing)');
const saved = JSON.parse(fs.readFileSync(ECDH_FILE, 'utf8'));
const privateKey = crypto.createPrivateKey({
key: Buffer.from(saved.privateKey, 'base64'),
format: 'der',
type: 'pkcs8',
});
const serverPubKey = crypto.createPublicKey({
key: Buffer.from(serverX509B64, 'base64'),
format: 'der',
type: 'spki',
});
const secret = crypto.diffieHellman({privateKey, publicKey: serverPubKey});
console.log('ECDH (saved key) shared secret derived, length:', secret.length);
return secret;
};
// ─── Helpers ───
const hexToBytes = (hex) => {
const bytes = [];
for (let i = 0; i < hex.length; i += 2) {
bytes.push(parseInt(hex.substring(i, i + 2), 16));
}
return Buffer.from(bytes);
};
// ─── OCRA-1 TOTP (matches Kaspi vtoken) ───
export const computeTokenSnMac = (tokenSN, secret) => {
if (!secret) return '000000';
const timeStep = BigInt(Date.now()) / BigInt(30000);
const timeHex = timeStep.toString(16);
const qHex = Buffer.from(tokenSN || '00000000')
.toString('hex')
.substring(0, 64);
const suiteBytes = Buffer.from(vtokenSuite);
const separator = Buffer.from([0x00]);
const qPadded = qHex.padEnd(256, '0');
const qBytes = hexToBytes(qPadded);
const tPadded = timeHex.padStart(16, '0');
const tBytes = hexToBytes(tPadded);
const dataBuffer = Buffer.concat([suiteBytes, separator, qBytes, tBytes]);
const hash = crypto.createHmac('sha256', secret).update(dataBuffer).digest();
// Dynamic truncation (RFC 4226)
const offset = hash[hash.length - 1] & 0x0f;
const binCode =
((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
return (binCode % 1000000).toString().padStart(6, '0');
};
// ─── ECDSA signing ───
export const ecSign = (data) => {
const sign = crypto.createSign('SHA256');
sign.update(data);
sign.end();
return sign.sign(ecKeyPair.privateKey).toString('base64');
};
export const signDataPayload = (dataB64) => ecSign(dataB64);
export const computeXSU = (url) => crypto.createHash('md5').update(url.toLowerCase()).digest('hex');
export const computeXSign = (url, headers, xshList) => {
const parts = xshList.split(',').map((name) => {
if (name === 'url') {
try {
const u = new URL(url);
return u.pathname + u.search;
} catch {
return url;
}
}
return headers[name] || '';
});
return ecSign(parts.join(''));
};

105
src/helpers.js Normal file
View File

@ -0,0 +1,105 @@
import crypto from 'crypto';
import fetch from 'node-fetch';
import { DEVICE, APP, UA_NATIVE } from './config.js';
import { computeTokenSnMac, computeXSign } from './crypto.js';
// ─── Utilities ───
export const generateUUID = () => crypto.randomUUID().toUpperCase();
export const nowISO = () => {
const d = new Date();
const off = -d.getTimezoneOffset();
const sign = off >= 0 ? '+' : '-';
const hh = String(Math.floor(Math.abs(off) / 60)).padStart(2, '0');
const mm = String(Math.abs(off) % 60).padStart(2, '0');
return (
d
.toISOString()
.replace('Z', '')
.replace(/\.\d{3}/, `.${String(d.getMilliseconds()).padStart(3, '0')}`) +
sign +
hh +
mm
);
};
// ─── Cookie builder ───
export const entranceCookie = (extraUserToken) => {
let c = `deviceId=${DEVICE.deviceId}; installId=${DEVICE.installId}; is_mobile_app=true; locale=${APP.locale}; ma_bld=${APP.build}; ma_platform_type=${APP.platform}; ma_platform_ver=${APP.platformVer}; ma_ver=${APP.version}; pk=${DEVICE.pk}; pkTag=${DEVICE.pkTag}; xs=R:0|E:0|RH:0|N:0`;
if (extraUserToken) c += `; user_token=${extraUserToken}`;
return c;
};
// ─── Extract user_token from set-cookie ───
export const extractUserToken = (resp) => {
const raw = resp.headers.raw()['set-cookie'] || [];
for (const c of raw) {
const m = c.match(/user_token=([^;]+)/);
if (m) return m[1];
}
return null;
};
// ─── Logged fetch wrapper ───
export const loggedFetch = async (url, options = {}) => {
const method = (options.method || 'GET').toUpperCase();
console.log(`\n>>> ${method} ${url}`);
if (options.headers) console.log('>>> Headers:', JSON.stringify(options.headers, null, 2));
if (options.body) {
try {
console.log('>>> Body:', JSON.parse(options.body));
} catch {
console.log('>>> Body:', options.body);
}
}
const resp = await fetch(url, options);
const cloned = resp.clone();
let body;
try {
body = await cloned.json();
} catch {
try {
body = await cloned.text();
} catch {
body = '[unreadable]';
}
}
console.log(`<<< ${resp.status} ${resp.statusText}`);
console.log('<<< Response:', typeof body === 'object' ? JSON.stringify(body, null, 2) : body);
return resp;
};
// ─── Signed QR-pay headers (session passed as parameter) ───
export const signedQrPayHeaders = (url, session) => {
const xsh =
'url,X-Request-ID,X-Device-ID,X-Platform-Ver,X-App-Bld,X-Time,X-Kb-TokenSn,X-App-Ver,X-Kb-TokenSnMac,X-Call,X-PI,X-Install-ID,X-Platform-Type,X-Locale,X-SV';
const headers = {
'X-Kb-TokenSn': session.tokenSN,
'X-Kb-TokenSnMac': computeTokenSnMac(session.tokenSN, session.decryptedSecret),
'X-PI': session.profileId != null ? String(session.profileId) : '',
'X-Install-ID': DEVICE.installId,
'X-Device-ID': DEVICE.deviceId,
'X-App-Ver': APP.version,
'X-App-Bld': APP.build,
'X-Platform-Type': APP.platform,
'X-Platform-Ver': APP.platformVer,
'X-Locale': APP.locale,
'X-Time': nowISO(),
'X-Request-ID': generateUUID(),
'X-Call': 'notConnected',
'X-SV': '2',
'X-SH': xsh,
'User-Agent': UA_NATIVE,
Accept: '*/*',
'Accept-Language': 'ru',
'Accept-Encoding': 'gzip, deflate, br',
};
headers['X-Sign'] = computeXSign(url, headers, xsh);
return headers;
};

50
src/logger.js Normal file
View File

@ -0,0 +1,50 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOGS_DIR = path.join(__dirname, '..', 'logs');
// Ensure logs directory exists
if (!fs.existsSync(LOGS_DIR)) {
fs.mkdirSync(LOGS_DIR, { recursive: true });
}
const getLogFile = () => {
const now = new Date();
const date = now.toISOString().slice(0, 10); // YYYY-MM-DD
return path.join(LOGS_DIR, `${date}.log`);
};
const formatTimestamp = () => {
return new Date().toISOString();
};
const writeLine = (level, tag, message, extra) => {
const ts = formatTimestamp();
let line = `[${ts}] [${level}] [${tag}] ${message}`;
if (extra !== undefined) {
line += typeof extra === 'string' ? ` ${extra}` : ` ${JSON.stringify(extra)}`;
}
line += '\n';
// Write to file
try {
fs.appendFileSync(getLogFile(), line);
} catch (err) {
console.error('Failed to write log:', err.message);
}
// Also output to console
if (level === 'ERROR') {
console.error(line.trimEnd());
} else {
console.log(line.trimEnd());
}
};
export const logger = {
info: (tag, message, extra) => writeLine('INFO', tag, message, extra),
warn: (tag, message, extra) => writeLine('WARN', tag, message, extra),
error: (tag, message, extra) => writeLine('ERROR', tag, message, extra),
};

394
src/polling.js Normal file
View File

@ -0,0 +1,394 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import fetch from 'node-fetch';
import { fileURLToPath } from 'url';
import { KASPI_QRPAY_URL } from './config.js';
import { signedQrPayHeaders } from './helpers.js';
import { decryptSecret } from './crypto.js';
import { getWebhooksByEvent } from './webhookStore.js';
import { logger } from './logger.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const TRACKED_FILE = path.join(__dirname, '..', 'tracked-payments.json');
// ─── Tracked payments ───
const trackedPayments = new Map();
// ─── Persistence ───
const saveTracked = () => {
try {
const data = Object.fromEntries(trackedPayments);
fs.writeFileSync(TRACKED_FILE, JSON.stringify(data, null, 2));
} catch (err) {
logger.error('POLLING', 'Failed to save tracked payments', err.message);
}
};
const loadTracked = () => {
try {
if (!fs.existsSync(TRACKED_FILE)) return;
const raw = fs.readFileSync(TRACKED_FILE, 'utf8');
const data = JSON.parse(raw);
for (const [id, entry] of Object.entries(data)) {
trackedPayments.set(id, entry);
}
if (trackedPayments.size > 0) {
logger.info('POLLING', `Restored ${trackedPayments.size} tracked payments from file`);
}
} catch (err) {
logger.error('POLLING', 'Failed to load tracked payments', err.message);
}
};
// ─── Pending retries (persisted) ───
const RETRY_FILE = path.join(__dirname, '..', 'webhook-retries.json');
let pendingRetries = [];
const saveRetries = () => {
try {
fs.writeFileSync(RETRY_FILE, JSON.stringify(pendingRetries, null, 2));
} catch (err) {
logger.error('WEBHOOK', 'Failed to save retries', err.message);
}
};
const loadRetries = () => {
try {
if (!fs.existsSync(RETRY_FILE)) return;
const raw = fs.readFileSync(RETRY_FILE, 'utf8');
pendingRetries = JSON.parse(raw);
if (pendingRetries.length > 0) {
logger.info('WEBHOOK', `Restored ${pendingRetries.length} pending retries from file`);
}
} catch (err) {
logger.error('WEBHOOK', 'Failed to load retries', err.message);
pendingRetries = [];
}
};
// ─── Status → event mapping ───
const QR_FINAL_STATUSES = {
Processed: 'payment.success',
CancelledByUser: 'payment.failed',
NotConfirmedByUser: 'payment.failed',
CancelledByExternalSource: 'payment.failed',
ProcessingFailed: 'payment.failed',
Rejected: 'payment.failed',
InsufficientFunds: 'payment.failed',
InsufficientFundsError: 'payment.failed',
Error: 'payment.failed',
IrisSrcBlockCode1: 'payment.failed',
IrisSrcBlockCode3: 'payment.failed',
IrisSrcBlockCode9: 'payment.failed',
IrisDestBlockCode3: 'payment.failed',
IrisDestBlockCode5: 'payment.failed',
IrisDestBlockCode7: 'payment.failed',
IrisDestBlockCode10: 'payment.failed',
QrTokenDiscarded: 'payment.expired',
Expired: 'payment.expired',
};
const INVOICE_FINAL_STATUSES = {
Processed: 'payment.success',
RemotePaymentCanceled: 'payment.failed',
RemotePaymentRejected: 'payment.failed',
Expired: 'payment.expired',
};
const QR_INTERMEDIATE = new Set(['QrTokenCreated', 'Wait']);
const INVOICE_INTERMEDIATE = new Set(['RemotePaymentCreated']);
// ─── Track a payment ───
export const trackPayment = (paymentId, type, sessionHeaders, meta = {}) => {
trackedPayments.set(String(paymentId), {
paymentId: String(paymentId),
type,
status: type === 'qr' ? 'QrTokenCreated' : 'RemotePaymentCreated',
sessionHeaders,
meta,
createdAt: Date.now(),
retryCount: 0,
});
saveTracked();
logger.info('POLLING', `Tracking ${type} payment ${paymentId}`);
};
// ─── Fetch status from Kaspi (quiet — no loggedFetch) ───
const fetchStatus = async (entry) => {
const { paymentId, type, sessionHeaders } = entry;
let decryptedSecret;
try {
decryptedSecret = decryptSecret(sessionHeaders.vtokenSecret);
} catch {
logger.error('POLLING', `Failed to decrypt session for payment ${paymentId} — session may have expired`);
return { error: 'session_expired' };
}
const session = {
tokenSN: sessionHeaders.tokenSN,
decryptedSecret,
profileId: sessionHeaders.profileId,
};
let url;
if (type === 'qr') {
url = `${KASPI_QRPAY_URL}/v02/kaspi-qr/status?qrOperationId=${paymentId}`;
} else {
url = `${KASPI_QRPAY_URL}/v02/remote/details?operationId=${paymentId}`;
}
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
const resp = await fetch(url, {
headers: signedQrPayHeaders(url, session),
signal: controller.signal,
});
clearTimeout(timer);
const json = await resp.json();
return json;
} catch (err) {
logger.error('POLLING', `Error fetching status for ${paymentId}:`, err.message);
return null;
}
};
// ─── Send webhooks ───
const fetchWithTimeout = async (url, options, timeoutMs = 10000) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetch(url, { ...options, signal: controller.signal });
clearTimeout(timer);
return resp;
} catch (err) {
clearTimeout(timer);
throw err;
}
};
const sendWebhook = async (hook, payload, attempt = 1) => {
const body = JSON.stringify(payload);
const signature =
'sha256=' +
crypto
.createHmac('sha256', hook.secret || '')
.update(body)
.digest('hex');
try {
const resp = await fetchWithTimeout(hook.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
},
body,
});
logger.info('WEBHOOK', `${hook.url} | ${resp.status} ${resp.statusText}`);
// Remove from pending retries on success
pendingRetries = pendingRetries.filter(
(r) =>
!(r.hook.url === hook.url && r.payload.paymentId === payload.paymentId && r.payload.event === payload.event),
);
saveRetries();
} catch (err) {
logger.error('WEBHOOK', `${hook.url} | attempt ${attempt} FAILED: ${err.message}`);
if (attempt < 3) {
// Save retry to disk so it survives restarts
pendingRetries.push({
hook,
payload,
attempt: attempt + 1,
executeAfter: Date.now() + (attempt === 1 ? 5000 : 30000),
});
saveRetries();
} else {
logger.error('WEBHOOK', `${hook.url} | FAILED after 3 retries`);
// Remove from pending retries
pendingRetries = pendingRetries.filter(
(r) =>
!(r.hook.url === hook.url && r.payload.paymentId === payload.paymentId && r.payload.event === payload.event),
);
saveRetries();
}
}
};
const sendWebhooks = (event, payload) => {
const hooks = getWebhooksByEvent(event);
for (const hook of hooks) {
sendWebhook(hook, payload);
}
};
// ─── Process pending retries ───
const processRetries = async () => {
const now = Date.now();
const due = pendingRetries.filter((r) => r.executeAfter <= now);
// Remove due items from list before executing (they'll be re-added on failure)
pendingRetries = pendingRetries.filter((r) => r.executeAfter > now);
saveRetries();
for (const r of due) {
await sendWebhook(r.hook, r.payload, r.attempt);
}
};
// ─── Resolve event from status ───
const resolveEvent = (type, status) => {
if (type === 'qr') {
if (QR_INTERMEDIATE.has(status)) return null;
return QR_FINAL_STATUSES[status] || 'payment.failed';
} else {
if (INVOICE_INTERMEDIATE.has(status)) return null;
return INVOICE_FINAL_STATUSES[status] || 'payment.failed';
}
};
// ─── Poll cycle ───
const pollOnce = async () => {
let changed = false;
for (const [id, entry] of trackedPayments) {
// TTL check via expireDate
if (entry.meta.expireDate) {
const expiry = new Date(entry.meta.expireDate).getTime();
if (Date.now() > expiry && resolveEvent(entry.type, entry.status) === null) {
logger.info('POLLING', `Payment ${id} expired (TTL)`);
sendWebhooks(
'payment.expired',
buildPayload('payment.expired', entry, { Status: 'Expired', StatusDesc: 'Время оплаты истекло' }),
);
trackedPayments.delete(id);
changed = true;
continue;
}
}
const result = await fetchStatus(entry);
// Handle session expiration
if (result && result.error === 'session_expired') {
entry.retryCount++;
if (entry.retryCount > 3) {
logger.warn('POLLING', `Payment ${id} — session expired, sending session.expired webhook`);
sendWebhooks(
'payment.failed',
buildPayload('payment.failed', entry, {
Status: 'SessionExpired',
StatusDesc: 'Сессия Kaspi истекла, невозможно проверить статус платежа',
}),
);
trackedPayments.delete(id);
changed = true;
}
continue;
}
if (!result || !result.Data) {
entry.retryCount++;
if (entry.retryCount > 10) {
logger.warn('POLLING', `Removing payment ${id} after 10 failed attempts`);
trackedPayments.delete(id);
changed = true;
}
continue;
}
// Reset retry count on successful fetch
entry.retryCount = 0;
const newStatus = result.Data.Status;
if (newStatus === entry.status) continue;
logger.info('POLLING', `Payment ${id}: ${entry.status}${newStatus}`);
entry.status = newStatus;
changed = true;
const event = resolveEvent(entry.type, newStatus);
if (event) {
sendWebhooks(event, buildPayload(event, entry, result.Data));
trackedPayments.delete(id);
}
}
if (changed) {
saveTracked();
}
};
const buildPayload = (event, entry, data) => ({
event,
paymentId: entry.paymentId,
type: entry.type,
status: data.Status || entry.status,
statusDesc: data.StatusDesc || '',
amount: entry.meta.amount || data.Amount || null,
qrToken: entry.meta.qrToken || null,
receiptUrl: entry.meta.receiptUrl || data.ReceiptUrl || null,
orderNumber: entry.meta.orderNumber || data.OrderNumber || null,
data,
timestamp: new Date().toISOString(),
});
// ─── Polling loop (setTimeout-based, no overlap) ───
let pollActive = false;
let pollTimer = null;
const POLL_MS = 3000;
const scheduleNext = () => {
if (!pollActive) return;
pollTimer = setTimeout(async () => {
try {
if (trackedPayments.size > 0) {
await pollOnce();
}
// Process pending webhook retries
if (pendingRetries.length > 0) {
await processRetries();
}
} catch (err) {
logger.error('POLLING', 'Unexpected error:', err.message);
}
scheduleNext();
}, POLL_MS);
};
export const startPolling = () => {
if (pollActive) return;
// Load persisted state
loadTracked();
loadRetries();
pollActive = true;
scheduleNext();
logger.info('POLLING', 'Started (interval: 3s, persistence: enabled)');
};
export const stopPolling = () => {
pollActive = false;
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
saveTracked();
saveRetries();
logger.info('POLLING', 'Stopped');
};
export const getTrackedPayments = () => Object.fromEntries(trackedPayments);

507
src/routes/auth.js Normal file
View File

@ -0,0 +1,507 @@
import { Router } from 'express';
import { DEVICE, APP, UA_NATIVE, ENTRANCE_HEADERS_BASE, KASPI_ENTRANCE_URL, KASPI_MTOKEN_URL } from '../config.js';
import { createEmptySession, applyOrgContext } from '../session.js';
import {
generateECDH,
completeECDH,
completeECDHWithSaved,
computeTokenSnMac,
signDataPayload,
computeXSU,
computeXSign,
encryptSecret,
decryptSecret,
} from '../crypto.js';
import { loggedFetch, extractUserToken, entranceCookie, generateUUID, nowISO } from '../helpers.js';
const router = Router();
// In-flight auth sessions keyed by processId (temporary, cleared after finish)
const authSessions = new Map();
// ═══════════════════════════════════════════════════
// Step 1 — Init entrance (get processId)
// ═══════════════════════════════════════════════════
router.post('/init', async (req, res) => {
const session = createEmptySession();
try {
const resp = await loggedFetch(`${KASPI_ENTRANCE_URL}/api/v1/entrance/step`, {
method: 'POST',
headers: {
...ENTRANCE_HEADERS_BASE,
Referer: `${KASPI_ENTRANCE_URL}/process/entrance/?auth=2&appBuild=${APP.build}&appVersion=${APP.version}&platformVersion=${APP.platformVer}&platformType=IOS&deviceBrand=${APP.brand}&deviceModel=${APP.model}&deviceId=${DEVICE.deviceId}&installId=${DEVICE.installId}&frontCameraAvailable=true&sf=registration&pc=KPEntrance&noPass=0`,
Cookie: entranceCookie(),
},
body: JSON.stringify({
data: {},
Data: {
auth: '2',
appBuild: APP.build,
appVersion: APP.version,
platformVersion: APP.platformVer,
platformType: 'IOS',
deviceBrand: APP.brand,
deviceModel: APP.model,
deviceId: DEVICE.deviceId,
installId: DEVICE.installId,
frontCameraAvailable: 'true',
sf: 'registration',
pc: 'KPEntrance',
noPass: '0',
},
actType: 'Success',
}),
});
const ut = extractUserToken(resp);
if (ut) session.userToken = ut;
const body = await resp.json();
if (body.meta?.pId) {
session.processId = body.meta.pId;
authSessions.set(session.processId, session);
}
res.json({ success: !!session.processId, processId: session.processId, view: body.view?.code, body });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ═══════════════════════════════════════════════════
// Step 2 — Send phone number (triggers SMS)
// ═══════════════════════════════════════════════════
router.post('/send-phone', async (req, res) => {
const { phoneNumber, processId } = req.body;
if (!phoneNumber) return res.status(400).json({ error: 'phoneNumber required (e.g. 7XXXXXXXXX)' });
if (!processId) return res.status(400).json({ error: 'processId required (from /api/auth/init)' });
const session = authSessions.get(processId);
if (!session) return res.status(400).json({ error: 'Unknown processId. Call /api/auth/init first' });
session.phoneNumber = phoneNumber;
try {
const resp = await loggedFetch(`${KASPI_ENTRANCE_URL}/api/v1/entrance/step`, {
method: 'POST',
headers: {
...ENTRANCE_HEADERS_BASE,
Referer: `${KASPI_ENTRANCE_URL}/process/universal-enter-phone-number?pId=${session.processId}&firstPage=KPUniversalEnterPhoneNumber`,
Cookie: entranceCookie(session.userToken),
},
body: JSON.stringify({
meta: { pId: session.processId, sn: 'EnterPhoneNumber' },
data: { phoneNumber },
actType: 'Success',
}),
});
const ut = extractUserToken(resp);
if (ut) session.userToken = ut;
const body = await resp.json();
const smsSent = body.view?.code === 'EnterOtp';
res.json({ success: smsSent, processId: session.processId, desc: body.data?.desc, view: body.view?.code, body });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ═══════════════════════════════════════════════════
// Step 3 — Submit SMS OTP code
// ═══════════════════════════════════════════════════
router.post('/verify-otp', async (req, res) => {
const { otp, processId } = req.body;
if (!otp) return res.status(400).json({ error: 'otp required' });
if (!processId) return res.status(400).json({ error: 'processId required' });
const session = authSessions.get(processId);
if (!session) return res.status(400).json({ error: 'Unknown processId' });
try {
const resp = await loggedFetch(`${KASPI_ENTRANCE_URL}/api/v1/entrance/step`, {
method: 'POST',
headers: {
...ENTRANCE_HEADERS_BASE,
Referer: `${KASPI_ENTRANCE_URL}/process/universal-enter-phone-number?pId=${session.processId}&firstPage=KPUniversalEnterPhoneNumber`,
Cookie: entranceCookie(session.userToken),
},
body: JSON.stringify({
meta: { pId: session.processId, sn: 'ViewEnterOtp' },
data: { userOtp: otp, inputType: 'auto' },
actType: 'Success',
}),
});
const ut = extractUserToken(resp);
if (ut) session.userToken = ut;
const body = await resp.json();
if (body.data?.type === 'kpDeviceRegistration' || body.view?.code === 'KPMobileCall') {
// OTP verified — automatically call finish
const finishResult = await doFinish(session);
authSessions.delete(processId);
res.json({
success: true,
processId: session.processId,
step: 'finished',
message: 'OTP verified and finish completed',
otpBody: body,
...finishResult,
});
} else {
res.json({ success: false, processId: session.processId, step: 'otp_response', body });
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ═══════════════════════════════════════════════════
// Finish logic (shared by verify-otp and /finish)
// ═══════════════════════════════════════════════════
async function doFinish(session) {
const ecdhX509 = generateECDH();
console.log('Generated ECDH public key for guard.x509:', ecdhX509);
const signedDataObj = {
installId: DEVICE.installId,
time: nowISO(),
auth: [{ value: '', type: 'pincode' }],
userIdHash: '',
};
const signedDataB64 = Buffer.from(JSON.stringify(signedDataObj)).toString('base64');
const finishUrl = `${KASPI_ENTRANCE_URL}/api/v1/kpentrance/finish`;
const finishHeaders = {
'Content-Type': 'application/json',
Accept: '*/*',
'Accept-Language': 'ru',
'Accept-Encoding': 'gzip, deflate, br',
'User-Agent': UA_NATIVE,
'X-Time': nowISO(),
'X-Call': 'notConnected',
'X-Platform-Type': APP.platform,
'X-PkTag': DEVICE.pkTag,
'X-SU': computeXSU(finishUrl),
'X-Net-Type': 'WIFI/ETHERNET',
'X-Emulator': '0',
'X-Locale': APP.locale,
'X-SV': '2',
'X-Request-ID': generateUUID(),
'X-Time-Zone': 'GMT+05:00',
'X-SH': 'url,X-Time-Zone,X-Request-ID,X-Net-Type,X-Emulator,X-Call,X-Platform-Type,X-Locale,X-Time,X-SV',
};
finishHeaders['X-Sign'] = computeXSign(finishUrl, finishHeaders, finishHeaders['X-SH']);
const resp = await loggedFetch(finishUrl, {
method: 'POST',
headers: finishHeaders,
body: JSON.stringify({
signed: { sign: signDataPayload(signedDataB64), data: signedDataB64 },
guard: { pinHash: DEVICE.pinHash, x509: ecdhX509 },
processId: session.processId,
}),
});
const body = await resp.json();
if (body.success && body.data?.tokenSN) {
session.tokenSN = body.data.tokenSN;
let vtokenSecret = null;
let rawSecret = null;
if (body.data.x509) {
try {
rawSecret = completeECDH(body.data.x509);
vtokenSecret = encryptSecret(rawSecret);
console.log('vtoken activated successfully');
} catch (e) {
console.error('ECDH key agreement failed:', e.message);
}
}
// Fetch org context
const orgUrl = `${KASPI_MTOKEN_URL}/v08/organizations/org-context-otp`;
const piValue = session.profileId != null ? String(session.profileId) : '';
const orgHeaders = {
'Content-Type': 'application/json',
Accept: '*/*',
'Accept-Language': 'ru',
'Accept-Encoding': 'gzip, deflate, br',
'User-Agent': UA_NATIVE,
'X-Kb-TokenSn': session.tokenSN,
'X-Kb-TokenSnMac': computeTokenSnMac(session.tokenSN, rawSecret),
'X-Install-ID': DEVICE.installId,
'X-App-Ver': APP.version,
'X-App-Bld': APP.build,
'X-Locale': APP.locale,
'X-Call': 'notConnected',
'X-Time': nowISO(),
'X-S': 'R:0|E:0|RH:0|N:0',
'X-SV': '2',
'X-Kb-Client-Ip': '192.168.1.96',
'X-PkTag': DEVICE.pkTag,
'X-SU': computeXSU(orgUrl),
'X-SH': piValue
? 'url,X-Kb-Client-Ip,X-App-Bld,X-S,X-Kb-TokenSn,X-Time,X-App-Ver,X-Kb-TokenSnMac,X-Call,X-PI,X-Install-ID,X-Locale,X-SV'
: 'url,X-Kb-Client-Ip,X-Time,X-App-Ver,X-SV,X-Locale,X-App-Bld,X-Install-ID,X-Kb-TokenSn,X-S,X-Kb-TokenSnMac,X-Call',
'X-Request-ID': generateUUID(),
};
if (piValue) orgHeaders['X-PI'] = piValue;
orgHeaders['X-Sign'] = computeXSign(orgUrl, orgHeaders, orgHeaders['X-SH']);
const orgResp = await loggedFetch(orgUrl, {
method: 'POST',
headers: orgHeaders,
body: JSON.stringify({
DeviceInformation: {
SdkVersion: 'AOTP service',
DeviceId: DEVICE.deviceId,
ApplicationId: 'kz.kaspi.business',
ScreenWidth: APP.screenW,
Model: APP.model,
ScreenHeight: APP.screenH,
DeviceName: APP.deviceName,
VersionName: APP.version,
BuildRelease: `${APP.platform} ${APP.platformVer}`,
Brand: APP.brand,
Board: APP.platformVer,
Platform: APP.platform,
Product: 'Kaspi Pay',
frontCameraAvailable: true,
VersionCode: APP.build,
InstallId: DEVICE.installId,
},
OrganizationId: 0,
}),
});
const orgBody = await orgResp.json();
if (orgBody.Data?.Current?.ProfileId) {
applyOrgContext(session, orgBody.Data);
}
return {
tokenSN: session.tokenSN,
vtokenSecret,
profileId: session.profileId,
organizationId: session.organizationId,
orgName: session.orgName,
phone: session.phoneNumber,
organizations: orgBody.Data?.Organizations,
};
} else {
throw new Error('Finish failed: ' + JSON.stringify(body));
}
}
// ═══════════════════════════════════════════════════
// Refresh — SignInLite (new tokenSN + vtokenSecret)
// POST /v03/auth/sign-in-lite
// ═══════════════════════════════════════════════════
router.post('/refresh', async (req, res) => {
const { tokenSN, vtokenSecret, organizationId } = req.body;
if (!tokenSN) return res.status(400).json({ error: 'tokenSN required' });
if (!vtokenSecret) return res.status(400).json({ error: 'vtokenSecret required' });
try {
const rawSecret = decryptSecret(vtokenSecret);
const liteUrl = `${KASPI_MTOKEN_URL}/v03/auth/sign-in-lite`;
const liteHeaders = {
'Content-Type': 'application/json',
Accept: '*/*',
'Accept-Language': 'ru',
'Accept-Encoding': 'gzip, deflate, br',
'User-Agent': UA_NATIVE,
'X-Kb-TokenSn': tokenSN,
'X-Kb-TokenSnMac': computeTokenSnMac(tokenSN, rawSecret),
'X-Install-ID': DEVICE.installId,
'X-App-Ver': APP.version,
'X-App-Bld': APP.build,
'X-Locale': APP.locale,
'X-Call': 'notConnected',
'X-Time': nowISO(),
'X-S': 'R:0|E:0|RH:0|N:0',
'X-SV': '2',
'X-Kb-Client-Ip': '192.168.1.96',
'X-PkTag': DEVICE.pkTag,
'X-SU': computeXSU(liteUrl),
'X-SH':
'url,X-Kb-Client-Ip,X-Time,X-App-Ver,X-SV,X-Locale,X-App-Bld,X-Install-ID,X-Kb-TokenSn,X-S,X-Kb-TokenSnMac,X-Call',
'X-Request-ID': generateUUID(),
};
liteHeaders['X-Sign'] = computeXSign(liteUrl, liteHeaders, liteHeaders['X-SH']);
const resp = await loggedFetch(liteUrl, {
method: 'POST',
headers: liteHeaders,
body: JSON.stringify({
OrganizationId: organizationId || 0,
DeviceInformation: {
SdkVersion: 'AOTP service',
DeviceId: DEVICE.deviceId,
ApplicationId: 'kz.kaspi.business',
ScreenWidth: APP.screenW,
Model: APP.model,
ScreenHeight: APP.screenH,
DeviceName: APP.deviceName,
VersionName: APP.version,
BuildRelease: `${APP.platform} ${APP.platformVer}`,
Brand: APP.brand,
Board: APP.platformVer,
Platform: APP.platform,
Product: 'Kaspi Pay',
frontCameraAvailable: true,
VersionCode: APP.build,
InstallId: DEVICE.installId,
},
}),
});
const body = await resp.json();
if (body.StatusCode === 0 && body.Data) {
const newTokenSN = body.Data.TokenSn || body.Data.tokenSN || tokenSN;
let newVtokenSecret = vtokenSecret;
let newRawSecret = null;
const serverX509 = body.Data.X509 || body.Data.x509;
if (serverX509) {
try {
newRawSecret = completeECDHWithSaved(serverX509);
newVtokenSecret = encryptSecret(newRawSecret);
console.log('SignInLite: new vtoken activated successfully');
} catch (e) {
console.error('SignInLite ECDH failed:', e.message);
}
}
const activeRawSecret = newRawSecret || decryptSecret(newVtokenSecret);
// ── Step 2: org-context-otp to load organization context ──
const session = createEmptySession();
session.tokenSN = newTokenSN;
let orgContextOk = false;
// Pre-fill from SignInLite response if available
if (body.Data.OrganizationContext || body.Data.OrganizationContextLite) {
applyOrgContext(session, body.Data.OrganizationContext || body.Data.OrganizationContextLite);
}
try {
const orgUrl = `${KASPI_MTOKEN_URL}/v08/organizations/org-context-otp`;
const orgHeaders = {
'Content-Type': 'application/json',
Accept: '*/*',
'Accept-Language': 'ru',
'Accept-Encoding': 'gzip, deflate, br',
'User-Agent': UA_NATIVE,
'X-Kb-TokenSn': newTokenSN,
'X-Kb-TokenSnMac': computeTokenSnMac(newTokenSN, activeRawSecret),
'X-Install-ID': DEVICE.installId,
'X-App-Ver': APP.version,
'X-App-Bld': APP.build,
'X-Locale': APP.locale,
'X-Call': 'notConnected',
'X-Time': nowISO(),
'X-S': 'R:0|E:0|RH:0|N:0',
'X-SV': '2',
'X-Kb-Client-Ip': '192.168.1.96',
'X-PkTag': DEVICE.pkTag,
'X-PI': session.profileId || '',
'X-SU': computeXSU(orgUrl),
'X-SH':
'url,X-Kb-Client-Ip,X-Time,X-App-Ver,X-SV,X-Locale,X-App-Bld,X-Install-ID,X-Kb-TokenSn,X-S,X-Kb-TokenSnMac,X-Call',
'X-Request-ID': generateUUID(),
};
orgHeaders['X-Sign'] = computeXSign(orgUrl, orgHeaders, orgHeaders['X-SH']);
const orgResp = await loggedFetch(orgUrl, {
method: 'POST',
headers: orgHeaders,
body: JSON.stringify({
OrganizationId: organizationId || session.organizationId || 0,
DeviceInformation: {
SdkVersion: 'AOTP service',
DeviceId: DEVICE.deviceId,
ApplicationId: 'kz.kaspi.business',
ScreenWidth: APP.screenW,
Model: APP.model,
ScreenHeight: APP.screenH,
DeviceName: APP.deviceName,
VersionName: APP.version,
BuildRelease: `${APP.platform} ${APP.platformVer}`,
Brand: APP.brand,
Board: APP.platformVer,
Platform: APP.platform,
Product: 'Kaspi Pay',
frontCameraAvailable: true,
VersionCode: APP.build,
InstallId: DEVICE.installId,
},
}),
});
const orgBody = await orgResp.json();
if (orgBody.StatusCode === 0 && orgBody.Data) {
applyOrgContext(session, orgBody.Data);
orgContextOk = true;
console.log('Refresh org-context-otp: OK, profileId:', session.profileId, 'orgId:', session.organizationId);
} else {
console.log('Refresh org-context-otp: failed (', orgBody.StatusCode, ')');
}
} catch (e) {
console.error('Refresh org-context-otp error:', e.message);
}
res.json({
success: true,
tokenSN: newTokenSN,
vtokenSecret: newVtokenSecret,
profileId: session.profileId,
organizationId: session.organizationId,
orgName: session.orgName,
sessionId: body.Data.SessionId,
organizations: body.Data.OrganizationContext?.Organizations || body.Data.OrganizationContextLite?.Organizations,
orgContext: orgContextOk,
message: 'Session refreshed via SignInLite + org-context',
});
} else {
res.json({
success: false,
statusCode: body.StatusCode,
message:
body.Message || body.Description || 'SignInLite failed — token may be expired, re-auth via SMS required',
body,
});
}
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ─── Session status (client sends tokenSN) ───
router.post('/session', (req, res) => {
const { tokenSN } = req.body || {};
res.json({ authenticated: !!tokenSN, tokenSN });
});
// ─── Logout ───
router.post('/logout', (req, res) => {
res.json({ success: true });
});
export default router;

75
src/routes/history.js Normal file
View File

@ -0,0 +1,75 @@
import { Router } from 'express';
import { KASPI_QRPAY_URL } from '../config.js';
import { loggedFetch, signedQrPayHeaders } from '../helpers.js';
import { decryptSecret } from '../crypto.js';
const router = Router();
// Extract session from request headers
const extractSession = (req) => ({
tokenSN: req.headers['x-token-sn'] || null,
profileId: req.headers['x-profile-id'] || null,
vtokenSecret: req.headers['x-vtoken-secret'] || null,
});
const requireAuth = (req, res, next) => {
const session = extractSession(req);
if (!session.tokenSN) return res.status(401).json({ error: 'Missing X-Token-SN header.' });
if (!session.vtokenSecret) return res.status(401).json({ error: 'Missing X-Vtoken-Secret header.' });
try {
session.decryptedSecret = decryptSecret(session.vtokenSecret);
} catch {
return res.status(401).json({ error: 'Invalid or expired vtokenSecret. Re-authenticate.' });
}
req.session = session;
next();
};
router.use(requireAuth);
// ─── Operations history (QR + remote) ───
router.post('/operations', async (req, res) => {
const { endDate, lastTransactionDate, statementPeriodCode } = req.body;
if (!endDate) return res.status(400).json({ error: 'endDate required' });
try {
const url = `${KASPI_QRPAY_URL}/v02/history/operations`;
const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' };
const resp = await loggedFetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
EndDate: endDate,
LastTransactionDate: lastTransactionDate || '',
StatementPeriodCode: statementPeriodCode ?? 0,
}),
});
res.json(await resp.json());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ─── Operation details ───
router.post('/details', async (req, res) => {
const { id, operationMethod } = req.body;
if (!id) return res.status(400).json({ error: 'id required' });
try {
const url = `${KASPI_QRPAY_URL}/v01/kaspi-qr/operations/details`;
const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' };
const resp = await loggedFetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
Id: Number(id),
OperationMethod: operationMethod ?? 0,
}),
});
res.json(await resp.json());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

137
src/routes/invoice.js Normal file
View File

@ -0,0 +1,137 @@
import { Router } from 'express';
import { KASPI_QRPAY_URL } from '../config.js';
import { loggedFetch, signedQrPayHeaders } from '../helpers.js';
import { decryptSecret } from '../crypto.js';
import { trackPayment } from '../polling.js';
const router = Router();
// Extract session from request headers
const extractSession = (req) => ({
tokenSN: req.headers['x-token-sn'] || null,
profileId: req.headers['x-profile-id'] || null,
vtokenSecret: req.headers['x-vtoken-secret'] || null,
});
const requireAuth = (req, res, next) => {
const session = extractSession(req);
if (!session.tokenSN) return res.status(401).json({ error: 'Missing X-Token-SN header.' });
if (!session.vtokenSecret) return res.status(401).json({ error: 'Missing X-Vtoken-Secret header.' });
try {
session.decryptedSecret = decryptSecret(session.vtokenSecret);
} catch {
return res.status(401).json({ error: 'Invalid or expired vtokenSecret. Re-authenticate.' });
}
req.session = session;
next();
};
router.use(requireAuth);
// ─── Client info ───
router.get('/client-info', async (req, res) => {
const { phoneNumber } = req.query;
if (!phoneNumber) return res.status(400).json({ error: 'phoneNumber required' });
try {
const url = `${KASPI_QRPAY_URL}/v01/remote/client-info?phoneNumber=${phoneNumber}`;
const resp = await loggedFetch(url, { headers: signedQrPayHeaders(url, req.session) });
res.json(await resp.json());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ─── Create invoice ───
router.post('/create', async (req, res) => {
const { phoneNumber, amount, comment } = req.body;
if (!phoneNumber || !amount) return res.status(400).json({ error: 'phoneNumber and amount required' });
try {
const url = `${KASPI_QRPAY_URL}/v01/remote/create`;
const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' };
const resp = await loggedFetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ PhoneNumber: phoneNumber, Amount: Number(amount), Comment: comment || '' }),
});
const kaspiResponse = await resp.json();
const d = kaspiResponse.Data;
if (d && d.Id && d.Status === 'RemotePaymentCreated') {
trackPayment(
d.Id,
'invoice',
{
tokenSN: req.session.tokenSN,
vtokenSecret: req.headers['x-vtoken-secret'],
profileId: req.session.profileId,
},
{
amount: d.Amount,
clientMobile: d.ClientMobile,
receiptUrl: d.ReceiptUrl,
orderNumber: d.OrderNumber,
},
);
}
res.json(kaspiResponse);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ─── Invoice details ───
router.get('/details', async (req, res) => {
const { operationId } = req.query;
if (!operationId) return res.status(400).json({ error: 'operationId required' });
try {
const url = `${KASPI_QRPAY_URL}/v02/remote/details?operationId=${operationId}`;
const resp = await loggedFetch(url, { headers: signedQrPayHeaders(url, req.session) });
res.json(await resp.json());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ─── Cancel invoice ───
router.post('/cancel', async (req, res) => {
const { operationId } = req.body;
if (!operationId) return res.status(400).json({ error: 'operationId required' });
try {
const url = `${KASPI_QRPAY_URL}/v01/remote/cancel`;
const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' };
const resp = await loggedFetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ qrOperationId: Number(operationId) }),
});
res.json(await resp.json());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ─── Invoice history ───
router.post('/history', async (req, res) => {
try {
const url = `${KASPI_QRPAY_URL}/v01/remote/history`;
const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' };
const resp = await loggedFetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ MaxResult: 20 }),
});
res.json(await resp.json());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

100
src/routes/qr.js Normal file
View File

@ -0,0 +1,100 @@
import { Router } from 'express';
import { KASPI_QRPAY_URL } from '../config.js';
import { loggedFetch, signedQrPayHeaders } from '../helpers.js';
import { decryptSecret } from '../crypto.js';
import { trackPayment } from '../polling.js';
const router = Router();
// Extract session from request headers
const extractSession = (req) => ({
tokenSN: req.headers['x-token-sn'] || null,
profileId: req.headers['x-profile-id'] || null,
vtokenSecret: req.headers['x-vtoken-secret'] || null,
});
const requireAuth = (req, res, next) => {
const session = extractSession(req);
if (!session.tokenSN) return res.status(401).json({ error: 'Missing X-Token-SN header.' });
if (!session.vtokenSecret) return res.status(401).json({ error: 'Missing X-Vtoken-Secret header.' });
try {
session.decryptedSecret = decryptSecret(session.vtokenSecret);
} catch {
return res.status(401).json({ error: 'Invalid or expired vtokenSecret. Re-authenticate.' });
}
req.session = session;
next();
};
router.use(requireAuth);
// ─── Create QR token ───
router.post('/create', async (req, res) => {
const { amount, latitude, longitude } = req.body;
if (!amount) return res.status(400).json({ error: 'amount required' });
try {
const url = `${KASPI_QRPAY_URL}/v01/qr-token/create`;
const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' };
const resp = await loggedFetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
PaymentAmount: Number(amount),
DeviceInterface: 'Pos',
Latitude: latitude || 43.204643483375889,
Longitude: longitude || 76.891962364115912,
}),
});
const kaspiResponse = await resp.json();
const d = kaspiResponse.Data;
if (d && d.QrOperationId) {
const opts = d.QrPaymentBehaviorOptions || {};
trackPayment(
d.QrOperationId,
'qr',
{
tokenSN: req.session.tokenSN,
vtokenSecret: req.headers['x-vtoken-secret'],
profileId: req.session.profileId,
},
{
qrToken: d.QrToken,
expireDate: d.ExpireDate,
receiptUrl: d.ReceiptUrl,
amount: d.Amount,
pollingIntervals: {
scanWaitTimeout: Number(opts.qrCodeScanWaitTimeout) || 180,
scanPollingInterval: Number(opts.qrCodeScanEventPollingInterval) || 3,
statusCountdown: Number(opts.paymentStatusCountdown) || 2,
confirmationTimeout: Number(opts.paymentConfirmationTimeout) || 65,
},
},
);
}
if (d && d.QrToken) {
d.QrToken = d.QrToken.replace('https://qr.kaspi.kz/', 'https://pay.kaspi.kz/pay/');
}
res.json(kaspiResponse);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ─── QR payment status ───
router.get('/status', async (req, res) => {
const { qrOperationId } = req.query;
if (!qrOperationId) return res.status(400).json({ error: 'qrOperationId required' });
try {
const url = `${KASPI_QRPAY_URL}/v02/kaspi-qr/status?qrOperationId=${qrOperationId}`;
const resp = await loggedFetch(url, { headers: signedQrPayHeaders(url, req.session) });
res.json(await resp.json());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

54
src/routes/refund.js Normal file
View File

@ -0,0 +1,54 @@
import { Router } from 'express';
import { KASPI_QRPAY_URL } from '../config.js';
import { loggedFetch, signedQrPayHeaders } from '../helpers.js';
import { decryptSecret } from '../crypto.js';
const router = Router();
// Extract session from request headers
const extractSession = (req) => ({
tokenSN: req.headers['x-token-sn'] || null,
profileId: req.headers['x-profile-id'] || null,
vtokenSecret: req.headers['x-vtoken-secret'] || null,
});
const requireAuth = (req, res, next) => {
const session = extractSession(req);
if (!session.tokenSN) return res.status(401).json({ error: 'Missing X-Token-SN header.' });
if (!session.vtokenSecret) return res.status(401).json({ error: 'Missing X-Vtoken-Secret header.' });
try {
session.decryptedSecret = decryptSecret(session.vtokenSecret);
} catch {
return res.status(401).json({ error: 'Invalid or expired vtokenSecret. Re-authenticate.' });
}
req.session = session;
next();
};
router.use(requireAuth);
// ─── Return (refund) ───
router.post('/create', async (req, res) => {
const { qrOperationId, returnAmount } = req.body;
if (!qrOperationId || !returnAmount)
return res.status(400).json({ error: 'qrOperationId and returnAmount required' });
try {
const url = `${KASPI_QRPAY_URL}/v01/kaspi-qr/history-pos-return`;
const headers = { ...signedQrPayHeaders(url, req.session), 'Content-Type': 'application/json' };
const resp = await loggedFetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
ReturnAmount: Number(returnAmount),
QrOperationId: Number(qrOperationId),
DeviceInterface: 'Pos',
}),
});
res.json(await resp.json());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

63
src/routes/session.js Normal file
View File

@ -0,0 +1,63 @@
import { Router } from 'express';
import { KASPI_QRPAY_URL } from '../config.js';
import { loggedFetch, signedQrPayHeaders } from '../helpers.js';
import { decryptSecret } from '../crypto.js';
const router = Router();
// Extract session from request headers
const extractSession = (req) => ({
tokenSN: req.headers['x-token-sn'] || null,
profileId: req.headers['x-profile-id'] || null,
vtokenSecret: req.headers['x-vtoken-secret'] || null,
});
// ─── Check session validity ───
router.get('/check', async (req, res) => {
const session = extractSession(req);
// 1. Check required headers
if (!session.tokenSN) return res.status(401).json({ active: false, error: 'Missing X-Token-SN header.' });
if (!session.vtokenSecret) return res.status(401).json({ active: false, error: 'Missing X-Vtoken-Secret header.' });
// 2. Try to decrypt vtokenSecret
try {
session.decryptedSecret = decryptSecret(session.vtokenSecret);
} catch {
return res.status(401).json({ active: false, error: 'Invalid or expired vtokenSecret. Re-authenticate.' });
}
// 3. Ping Kaspi API to verify the token is still accepted
try {
const url = `${KASPI_QRPAY_URL}/v02/history/operations`;
const headers = { ...signedQrPayHeaders(url, session), 'Content-Type': 'application/json' };
const resp = await loggedFetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
EndDate: new Date().toISOString().slice(0, 10),
LastTransactionDate: '',
StatementPeriodCode: 0,
}),
});
const body = await resp.json().catch(() => ({}));
// Kaspi may return HTTP 200 but with error StatusCode in body
if (resp.ok && (!body.StatusCode || body.StatusCode === 0)) {
return res.json({ active: true });
}
return res.status(resp.ok ? 401 : resp.status).json({
active: false,
error: body.Message || body.message || 'Session rejected by Kaspi API.',
code: body.StatusCode || body.Code,
details: body,
});
} catch (err) {
return res.status(500).json({ active: false, error: err.message });
}
});
export default router;

43
src/session.js Normal file
View File

@ -0,0 +1,43 @@
const SESSION_FIELDS = [
'processId',
'userToken',
'tokenSN',
'tokenSnMac',
'qrPayTokenSnMac',
'profileId',
'phoneNumber',
'orgName',
'userId',
'organizationId',
'organizationIdn',
'organizationKbe',
'empId',
'accessLevelType',
'isCashier',
'payerType',
'categoryName',
'possiblePaymentMethods',
'showFakeCard',
];
export const createEmptySession = () => Object.fromEntries(SESSION_FIELDS.map((k) => [k, null]));
export const sessionFields = () => [...SESSION_FIELDS];
export const applyOrgContext = (session, data) => {
const cur = data.Current || {};
session.profileId = cur.ProfileId || session.profileId;
session.orgName = cur.OrganizationName || session.orgName;
session.organizationId = cur.OrganizationId || session.organizationId;
session.organizationIdn = cur.OrganizationIdn || session.organizationIdn;
session.organizationKbe = cur.OrganizationKbe || session.organizationKbe;
session.empId = cur.EmpId || session.empId;
session.accessLevelType = cur.AccessLevelType ?? session.accessLevelType;
session.isCashier = cur.IsCashier ?? session.isCashier;
session.payerType = cur.PayerType || session.payerType;
session.categoryName = cur.CategoryName || session.categoryName;
session.possiblePaymentMethods = cur.PossiblePaymentMethods || session.possiblePaymentMethods;
session.showFakeCard = cur.ShowFakeCard ?? session.showFakeCard;
session.userId = data.UserId || session.userId;
session.phoneNumber = data.PhoneNumber || session.phoneNumber;
};

31
src/webhookStore.js Normal file
View File

@ -0,0 +1,31 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WEBHOOKS_FILE = path.join(__dirname, '..', 'webhooks.json');
/**
* Читает webhooks.json и возвращает массив вебхуков.
* При ошибке чтения/парсинга возвращает [].
*/
export const loadWebhooks = () => {
try {
const raw = fs.readFileSync(WEBHOOKS_FILE, 'utf8');
const hooks = JSON.parse(raw);
if (!Array.isArray(hooks)) return [];
return hooks;
} catch (err) {
if (err.code !== 'ENOENT') {
console.error('[WEBHOOK STORE] Error reading webhooks.json:', err.message);
}
return [];
}
};
/**
* Возвращает вебхуки, подписанные на указанное событие.
*/
export const getWebhooksByEvent = (event) => {
return loadWebhooks().filter((hook) => hook.url && Array.isArray(hook.events) && hook.events.includes(event));
};

56
test/crypto.test.js Normal file
View File

@ -0,0 +1,56 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
// Set TOKEN_SECRET_KEY before importing crypto module
process.env.TOKEN_SECRET_KEY = 'a'.repeat(64);
const { encryptSecret, decryptSecret, computeTokenSnMac, computeXSU } = await import('../src/crypto.js');
describe('encryptSecret / decryptSecret', () => {
it('should round-trip a secret buffer', () => {
const original = Buffer.from('my-super-secret-value');
const encrypted = encryptSecret(original);
const decrypted = decryptSecret(encrypted);
assert.deepStrictEqual(decrypted, original);
});
it('should produce different ciphertexts for the same input (random IV)', () => {
const original = Buffer.from('test');
const a = encryptSecret(original);
const b = encryptSecret(original);
assert.notEqual(a, b);
});
it('should fail to decrypt tampered data', () => {
const encrypted = encryptSecret(Buffer.from('secret'));
const buf = Buffer.from(encrypted, 'base64');
buf[20] ^= 0xff; // tamper with ciphertext
assert.throws(() => decryptSecret(buf.toString('base64')));
});
});
describe('computeTokenSnMac', () => {
it('should return 6-digit string', () => {
const secret = Buffer.from('0123456789abcdef0123456789abcdef', 'hex');
const result = computeTokenSnMac('TSN12345', secret);
assert.match(result, /^\d{6}$/);
});
it('should return 000000 when secret is null', () => {
const result = computeTokenSnMac('TSN12345', null);
assert.equal(result, '000000');
});
});
describe('computeXSU', () => {
it('should return md5 hex of lowercased url', () => {
const result = computeXSU('https://example.com/Path');
assert.match(result, /^[0-9a-f]{32}$/);
});
it('should be case-insensitive', () => {
const a = computeXSU('HTTPS://EXAMPLE.COM');
const b = computeXSU('https://example.com');
assert.equal(a, b);
});
});

80
test/helpers.test.js Normal file
View File

@ -0,0 +1,80 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
// Set TOKEN_SECRET_KEY before importing (helpers imports from crypto via config chain)
process.env.TOKEN_SECRET_KEY = 'a'.repeat(64);
const { generateUUID, nowISO, entranceCookie, extractUserToken } = await import('../src/helpers.js');
describe('generateUUID', () => {
it('should return an uppercase UUID', () => {
const uuid = generateUUID();
assert.match(uuid, /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/);
});
it('should return unique values', () => {
const a = generateUUID();
const b = generateUUID();
assert.notEqual(a, b);
});
});
describe('nowISO', () => {
it('should return ISO-like string with timezone offset', () => {
const result = nowISO();
// Should match pattern like 2025-05-09T12:00:00.000+0600
assert.match(result, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{4}$/);
});
});
describe('entranceCookie', () => {
it('should contain deviceId and installId', () => {
const cookie = entranceCookie();
assert.ok(cookie.includes('deviceId='));
assert.ok(cookie.includes('installId='));
assert.ok(cookie.includes('is_mobile_app=true'));
});
it('should include user_token when provided', () => {
const cookie = entranceCookie('my-token');
assert.ok(cookie.includes('user_token=my-token'));
});
it('should not include user_token when not provided', () => {
const cookie = entranceCookie();
assert.ok(!cookie.includes('user_token='));
});
});
describe('extractUserToken', () => {
it('should extract user_token from set-cookie headers', () => {
const fakeResp = {
headers: {
raw: () => ({
'set-cookie': ['user_token=abc123; Path=/; HttpOnly'],
}),
},
};
assert.equal(extractUserToken(fakeResp), 'abc123');
});
it('should return null when no user_token cookie', () => {
const fakeResp = {
headers: {
raw: () => ({
'set-cookie': ['other=value; Path=/'],
}),
},
};
assert.equal(extractUserToken(fakeResp), null);
});
it('should return null when no set-cookie header', () => {
const fakeResp = {
headers: {
raw: () => ({}),
},
};
assert.equal(extractUserToken(fakeResp), null);
});
});

68
test/session.test.js Normal file
View File

@ -0,0 +1,68 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createEmptySession, sessionFields, applyOrgContext } from '../src/session.js';
describe('createEmptySession', () => {
it('should return an object with all fields set to null', () => {
const session = createEmptySession();
assert.equal(typeof session, 'object');
for (const key of sessionFields()) {
assert.equal(session[key], null, `${key} should be null`);
}
});
it('should return a new object each time', () => {
const a = createEmptySession();
const b = createEmptySession();
assert.notEqual(a, b);
});
});
describe('sessionFields', () => {
it('should return an array of strings', () => {
const fields = sessionFields();
assert.ok(Array.isArray(fields));
assert.ok(fields.length > 0);
for (const f of fields) {
assert.equal(typeof f, 'string');
}
});
it('should include critical fields', () => {
const fields = sessionFields();
assert.ok(fields.includes('processId'));
assert.ok(fields.includes('tokenSN'));
assert.ok(fields.includes('profileId'));
assert.ok(fields.includes('phoneNumber'));
});
});
describe('applyOrgContext', () => {
it('should populate session from Current data', () => {
const session = createEmptySession();
const data = {
UserId: 'U123',
PhoneNumber: '+77001234567',
Current: {
ProfileId: 42,
OrganizationName: 'Test Org',
OrganizationId: 'ORG1',
EmpId: 'E1',
IsCashier: true,
},
};
applyOrgContext(session, data);
assert.equal(session.userId, 'U123');
assert.equal(session.phoneNumber, '+77001234567');
assert.equal(session.profileId, 42);
assert.equal(session.orgName, 'Test Org');
assert.equal(session.isCashier, true);
});
it('should not overwrite existing values with falsy data', () => {
const session = createEmptySession();
session.profileId = 99;
applyOrgContext(session, { Current: {} });
assert.equal(session.profileId, 99);
});
});

7
webhooks.example.json Normal file
View File

@ -0,0 +1,7 @@
[
{
"url": "https://example.com/webhook",
"events": ["payment.success", "payment.failed", "payment.expired"],
"secret": "your-webhook-secret"
}
]