From 5924c1c6351a58a8b9aacc507fb73f310a5ba3d9 Mon Sep 17 00:00:00 2001 From: tjunussov Date: Sat, 18 Jul 2026 11:59:37 +0000 Subject: [PATCH] Setup Kaspi POS mini service --- .editorconfig | 12 + .env.example | 18 + .github/ISSUE_TEMPLATE/bug_report.md | 35 + .github/ISSUE_TEMPLATE/feature_request.md | 23 + .github/PULL_REQUEST_TEMPLATE.md | 19 + .gitignore | 19 + .prettierrc | 7 + AGENTS.md | 749 +++++++++ CHANGELOG.md | 23 + CONTRIBUTING.md | 50 + LICENSE | 21 + README.kk.md | 147 ++ README.md | 149 +- SECURITY.md | 30 + design.md | 142 ++ docs/API.kk.md | 693 ++++++++ docs/API.md | 693 ++++++++ eslint.config.js | 30 + kaspi-pos-automation | 1 + package-lock.json | 1847 +++++++++++++++++++++ package.json | 46 + public/app.js | 738 ++++++++ public/index.html | 483 ++++++ scripts/regen-device.js | 25 + scripts/regen-keypair.js | 22 + server.js | 30 + src/config.js | 113 ++ src/crypto.js | 161 ++ src/helpers.js | 105 ++ src/logger.js | 50 + src/polling.js | 394 +++++ src/routes/auth.js | 507 ++++++ src/routes/history.js | 75 + src/routes/invoice.js | 137 ++ src/routes/qr.js | 100 ++ src/routes/refund.js | 54 + src/routes/session.js | 63 + src/session.js | 43 + src/webhookStore.js | 31 + test/crypto.test.js | 56 + test/helpers.test.js | 80 + test/session.test.js | 68 + webhooks.example.json | 7 + 43 files changed, 8095 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.kk.md create mode 100644 SECURITY.md create mode 100644 design.md create mode 100644 docs/API.kk.md create mode 100644 docs/API.md create mode 100644 eslint.config.js create mode 160000 kaspi-pos-automation create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 scripts/regen-device.js create mode 100644 scripts/regen-keypair.js create mode 100644 server.js create mode 100644 src/config.js create mode 100644 src/crypto.js create mode 100644 src/helpers.js create mode 100644 src/logger.js create mode 100644 src/polling.js create mode 100644 src/routes/auth.js create mode 100644 src/routes/history.js create mode 100644 src/routes/invoice.js create mode 100644 src/routes/qr.js create mode 100644 src/routes/refund.js create mode 100644 src/routes/session.js create mode 100644 src/session.js create mode 100644 src/webhookStore.js create mode 100644 test/crypto.test.js create mode 100644 test/helpers.test.js create mode 100644 test/session.test.js create mode 100644 webhooks.example.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4a7ea30 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..74c4bba --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8a41fd9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug Report +about: Сообщить об ошибке +title: '[Bug] ' +labels: bug +assignees: '' +--- + +## Описание + +Краткое описание ошибки. + +## Шаги для воспроизведения + +1. ... +2. ... +3. ... + +## Ожидаемое поведение + +Что должно было произойти. + +## Фактическое поведение + +Что произошло на самом деле. + +## Окружение + +- Node.js версия: +- ОС: +- Версия проекта: + +## Логи / Скриншоты + +При наличии, приложите логи или скриншоты. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..4212505 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature Request +about: Предложить новую функциональность +title: '[Feature] ' +labels: enhancement +assignees: '' +--- + +## Описание + +Краткое описание предлагаемой функциональности. + +## Мотивация + +Какую проблему это решает? + +## Предлагаемое решение + +Как вы видите реализацию? + +## Альтернативы + +Рассматривали ли вы другие варианты? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..da11530 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## Описание + +Краткое описание изменений. + +## Тип изменений + +- [ ] Bug fix +- [ ] Новая функциональность +- [ ] Рефакторинг +- [ ] Документация +- [ ] Другое + +## Чеклист + +- [ ] Код проходит `npm run lint` +- [ ] Тесты проходят `npm test` +- [ ] Добавлены/обновлены тесты (если применимо) +- [ ] Обновлена документация (если применимо) +- [ ] Нет захардкоженных секретов или приватных данных diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f72af3a --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ada4aa5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "tabWidth": 2, + "printWidth": 120 +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cbe14e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,749 @@ + +# Vibe42 — учебная песочница: сайты, боты и первые приложения + +Workspace юзера `tjunussov`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение. + +--- + +## 🎯 ТВОЯ РОЛЬ + +Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с: +1. **работающим воплощением ЕГО идеи**: сайт — опубликован на `https://pages.git.vibe42.kz/tjunussov//`, бот/приложение — запущены через `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 + +Приложение + +

Записи

+
+ + + +``` + +`.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; +} +``` +Во фронте: `` + `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. **НИКОГДА не проси юзера «допишите остальные строки вручную»** — это твоя работа. Данных много (десятки+ строк)? НЕ хардкодь сотни `` в 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 + + + + + +Презентация + + + + +
+ +
+ + + +``` + +Правила: цвета/шрифты можно подгонять под тему юзера в ` + + +
+

Заголовок

+

Подзаголовок — пара предложений о чём это.

+ Начать +
+
+

Секция

+
Контент карточки.
+
Контент карточки.
+
+ + +``` + +## Что НЕ юзать + +- Bootstrap / Material UI / Chakra / Ant Design — слишком тяжело +- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG +- jQuery — vanilla JS достаточно + +## Что МОЖНО (если очень надо) + +- **Tailwind через CDN**: `` +- **Placeholder картинки**: `https://picsum.photos/800/600` +- **Google Fonts через ``** в head + +## 📦 Готовые рецепты (используй их, не изобретай) + +### Фото без ключей +- Детерминированные (одно и то же слово → одна и та же картинка): `https://picsum.photos/seed/coffee/800/600`. Меняй `coffee` на любое слово-seed. +- Тематические (по тегу): `https://loremflickr.com/800/600/coffee` — тег прямо в URL (`/coffee`, `/mountains`, `/food`). Тема не гарантируется на 100%, но обычно в кассу. + +### Кнопка WhatsApp +```html +Написать в WhatsApp +``` +`7XXXXXXXXXX` — номер в межд. формате без `+`. `text=` — предзаполненное сообщение. + +### Форма заявки без бэкенда +```html +
+ + + +
+``` +Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует, письмо-подтверждение придёт один раз). + +### Google Fonts (максимум 2 шрифта: один display + один text) +```html + + +``` +В CSS: `font-family:'Unbounded',sans-serif` для заголовков, `'Manrope',sans-serif` для текста. + +### Favicon-эмодзи (inline SVG data-URI, без файла) +```html + +``` +Меняй эмодзи `☕` на подходящее теме. + +### Карта (Google Maps embed, без ключа) +На maps.google.com найди место → «Поделиться» → «Встроить карту» → скопируй iframe. Вставь как есть: +```html + +``` + +### Плавные появления при скролле (IntersectionObserver) +```html + + +``` +Добавь класс `reveal` любой секции/карточке — появится плавно при прокрутке. diff --git a/docs/API.kk.md b/docs/API.kk.md new file mode 100644 index 0000000..6c82559 --- /dev/null +++ b/docs/API.kk.md @@ -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= +``` + +**Қабылдаушы жағында қолтаңбаны тексеру (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 → қаражатты қайтару (қажет болған жағдайда) +``` diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..c4a0f3a --- /dev/null +++ b/docs/API.md @@ -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= +``` + +**Проверка подписи на стороне получателя (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 → возврат средств (при необходимости) +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..d44d325 --- /dev/null +++ b/eslint.config.js @@ -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/'], + }, +]; diff --git a/kaspi-pos-automation b/kaspi-pos-automation new file mode 160000 index 0000000..cc9b393 --- /dev/null +++ b/kaspi-pos-automation @@ -0,0 +1 @@ +Subproject commit cc9b393ca0e3a040a359997082ebcc64b3bb2099 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e11f188 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1847 @@ +{ + "name": "kaspi-pos-automation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kaspi-pos-automation", + "version": "1.0.0", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=20.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c566fb1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..ec0b4d0 --- /dev/null +++ b/public/app.js @@ -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 = 'Отправка...'; + 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 = 'Проверка...'; + 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 `${label}`; +}; + +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 }) => + `
+ ${Title} + ${Data} +
`, + ) + .join(''); + el.innerHTML = `
${statusBadge(data.Status)}
${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 = 'Создание...'; + + 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 `QR Code`; +}; + +const createQr = async () => { + const amount = $('qrAmount').value; + if (!amount) return alert('Введите сумму'); + + const btn = $('btnCreateQr'); + btn.disabled = true; + btn.innerHTML = 'Создание...'; + + 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 = + '

Загрузка...

'; + try { + const resp = await apiPost('/api/invoice/history'); + const ops = resp.Data?.Operations || []; + if (!ops.length) { + list.innerHTML = '

Нет операций

'; + return; + } + list.innerHTML = ops + .map( + (op) => ` +
+
+
+
${op.ClientName || op.ClientShortName || '—'}
+
${new Date(op.OrderRegDate).toLocaleString('ru')}
+
+
+
${op.Amount}
+ ${statusBadge(op.Status)} +
+
+
`, + ) + .join(''); + } catch (e) { + list.innerHTML = `

Ошибка: ${e.message}

`; + } +}; + +const showHistoryDetail = async (opId) => { + historyOpId = opId; + const panel = $('historyDetail'); + const content = $('historyDetailContent'); + content.innerHTML = '

Загрузка...

'; + 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 = '

Ошибка

'; + } +}; + +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 = + '

Загрузка...

'; + 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 = `
+
Продажи
${s.SalesAmount ?? 0} ₸
${s.SalesCount ?? 0} шт
+
Возвраты
${s.ReturnsAmount ?? 0} ₸
${s.ReturnsCount ?? 0} шт
+
`; + // Operations list + const dailySets = data.DailySets || []; + if (!dailySets.length) { + list.innerHTML = '

Нет операций

'; + return; + } + let html = ''; + for (const day of dailySets) { + html += `
${day.Date || ''}
`; + for (const op of day.Operations || []) { + const isReturn = op.OperationType === 1; + const sign = isReturn ? '+' : ''; + const color = isReturn ? '#c62828' : '#1a1a1a'; + html += `
+
+
+
${op.ClientName || op.ClientShortName || '—'}
+
${op.Time || ''}
+
+
+
${sign}${op.Amount} ₸
+
+
+
`; + } + } + list.innerHTML = html; + } catch (e) { + list.innerHTML = `

Ошибка: ${e.message}

`; + } +}; + +const showSalesDetail = async (id, operationMethod) => { + salesOpId = id; + const panel = $('salesDetail'); + const content = $('salesDetailContent'); + const refundSection = $('refundSection'); + const refundMsg = $('refundMsg'); + content.innerHTML = '

Загрузка...

'; + 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 ? `Открыть` : null], + ]; + for (const [label, value] of fields) { + if (value != null) + rows += `
${label}${value}
`; + } + // Returns history + if (d.Returns && d.Returns.length) { + rows += '
Возвраты:
'; + for (const r of d.Returns) { + rows += `
${r.Date || ''}${r.Amount} ₸
`; + } + } + content.innerHTML = rows || '

Нет данных

'; + // 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 = `

Ошибка: ${e.message}

`; + } +}; + +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 = 'Возврат...'; + 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); + } +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..d6a9d24 --- /dev/null +++ b/public/index.html @@ -0,0 +1,483 @@ + + + + + + Kaspi Pay + + + +
+

🏦 Kaspi Pay

+ + +
+
+
+
+
+
+
+ + +
+

Вход по номеру телефона

+

Войдите с аккаунта кассира Kaspi Pay

+ +
+ +7 + +
+ +
+ + + + + + + + +
+
+ + + +
+ + + + diff --git a/scripts/regen-device.js b/scripts/regen-device.js new file mode 100644 index 0000000..8214dea --- /dev/null +++ b/scripts/regen-device.js @@ -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.'); diff --git a/scripts/regen-keypair.js b/scripts/regen-keypair.js new file mode 100644 index 0000000..f865b00 --- /dev/null +++ b/scripts/regen-keypair.js @@ -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.'); diff --git a/server.js b/server.js new file mode 100644 index 0000000..606a9a9 --- /dev/null +++ b/server.js @@ -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(); +}); diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..cc4b922 --- /dev/null +++ b/src/config.js @@ -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 }; diff --git a/src/crypto.js b/src/crypto.js new file mode 100644 index 0000000..e97ead0 --- /dev/null +++ b/src/crypto.js @@ -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('')); +}; diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 0000000..63e5bf2 --- /dev/null +++ b/src/helpers.js @@ -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; +}; diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..1a349ea --- /dev/null +++ b/src/logger.js @@ -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), +}; diff --git a/src/polling.js b/src/polling.js new file mode 100644 index 0000000..3ed0853 --- /dev/null +++ b/src/polling.js @@ -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); diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..075e674 --- /dev/null +++ b/src/routes/auth.js @@ -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; diff --git a/src/routes/history.js b/src/routes/history.js new file mode 100644 index 0000000..42dce66 --- /dev/null +++ b/src/routes/history.js @@ -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; diff --git a/src/routes/invoice.js b/src/routes/invoice.js new file mode 100644 index 0000000..84e7cf5 --- /dev/null +++ b/src/routes/invoice.js @@ -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; diff --git a/src/routes/qr.js b/src/routes/qr.js new file mode 100644 index 0000000..fc163cb --- /dev/null +++ b/src/routes/qr.js @@ -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; diff --git a/src/routes/refund.js b/src/routes/refund.js new file mode 100644 index 0000000..f53857f --- /dev/null +++ b/src/routes/refund.js @@ -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; diff --git a/src/routes/session.js b/src/routes/session.js new file mode 100644 index 0000000..db64371 --- /dev/null +++ b/src/routes/session.js @@ -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; diff --git a/src/session.js b/src/session.js new file mode 100644 index 0000000..3c9bef4 --- /dev/null +++ b/src/session.js @@ -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; +}; diff --git a/src/webhookStore.js b/src/webhookStore.js new file mode 100644 index 0000000..48aa3bc --- /dev/null +++ b/src/webhookStore.js @@ -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)); +}; diff --git a/test/crypto.test.js b/test/crypto.test.js new file mode 100644 index 0000000..7aa8a5b --- /dev/null +++ b/test/crypto.test.js @@ -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); + }); +}); diff --git a/test/helpers.test.js b/test/helpers.test.js new file mode 100644 index 0000000..451b011 --- /dev/null +++ b/test/helpers.test.js @@ -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); + }); +}); diff --git a/test/session.test.js b/test/session.test.js new file mode 100644 index 0000000..4eabcc3 --- /dev/null +++ b/test/session.test.js @@ -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); + }); +}); diff --git a/webhooks.example.json b/webhooks.example.json new file mode 100644 index 0000000..d356132 --- /dev/null +++ b/webhooks.example.json @@ -0,0 +1,7 @@ +[ + { + "url": "https://example.com/webhook", + "events": ["payment.success", "payment.failed", "payment.expired"], + "secret": "your-webhook-secret" + } +]