commit
aeec8261ea
@ -16,6 +16,8 @@ SMTP_MAIL_FROM=noreply@example.com # Email отправителя
|
|||||||
|
|
||||||
# === Безопасность ===
|
# === Безопасность ===
|
||||||
API_PASS=your_secure_password_here # Пароль для доступа к API (используйте сложный!)
|
API_PASS=your_secure_password_here # Пароль для доступа к API (используйте сложный!)
|
||||||
|
JWT_SECRET=your_long_random_jwt_secret # Секрет подписи JWT для админ-панели
|
||||||
|
ADMIN_SESSION_HOURS=12 # Срок действия JWT-сессии админа в часах
|
||||||
PORT=3000 # Порт, на котором будет работать сервер
|
PORT=3000 # Порт, на котором будет работать сервер
|
||||||
HOST=0.0.0.0 # Хост для облачного деплоя
|
HOST=0.0.0.0 # Хост для облачного деплоя
|
||||||
FNS_TIMEOUT_MS=30000 # Таймаут создания чека в ФНС
|
FNS_TIMEOUT_MS=30000 # Таймаут создания чека в ФНС
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -47,4 +47,6 @@ build/
|
|||||||
!README.md
|
!README.md
|
||||||
!README.MD
|
!README.MD
|
||||||
!server.js
|
!server.js
|
||||||
|
!public/
|
||||||
|
!public/**
|
||||||
!LICENSE
|
!LICENSE
|
||||||
|
|||||||
30
README.MD
30
README.MD
@ -12,6 +12,26 @@ npm start
|
|||||||
|
|
||||||
Сервис запустится на порту из `PORT` или на `4000` по умолчанию.
|
Сервис запустится на порту из `PORT` или на `4000` по умолчанию.
|
||||||
|
|
||||||
|
Админ-интерфейс доступен по адресу:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Для входа используется значение `API_PASS`. Через интерфейс можно смотреть локальный журнал чеков, синхронизировать последние чеки из кабинета ФНС, создавать чек вручную, проверять ФНС/SMTP и менять параметры сервиса без отдельного фронтенд-фреймворка.
|
||||||
|
|
||||||
|
Swagger UI доступен по адресу:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /swagger
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenAPI JSON доступен по адресу:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /openapi.json
|
||||||
|
```
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Сборка образа:
|
Сборка образа:
|
||||||
@ -42,13 +62,19 @@ SMTP_USER=noreply@example.com
|
|||||||
SMTP_PASS=email_app_password
|
SMTP_PASS=email_app_password
|
||||||
SMTP_MAIL_FROM=noreply@example.com
|
SMTP_MAIL_FROM=noreply@example.com
|
||||||
API_PASS=strong_api_password
|
API_PASS=strong_api_password
|
||||||
|
JWT_SECRET=long_random_jwt_secret
|
||||||
|
ADMIN_SESSION_HOURS=12
|
||||||
PORT=3000
|
PORT=3000
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
FNS_TIMEOUT_MS=30000
|
FNS_TIMEOUT_MS=30000
|
||||||
SMTP_TIMEOUT_MS=15000
|
SMTP_TIMEOUT_MS=15000
|
||||||
```
|
```
|
||||||
|
|
||||||
`API_PASS` должен совпадать с `api_pass` в запросах к `POST /api/v1/create-receipt`.
|
`API_PASS` используется как пароль входа в админ-панель и должен совпадать с `api_pass` в старых запросах к `POST /api/v1/create-receipt`. После входа UI получает JWT и дальше отправляет запросы с `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
`JWT_SECRET` лучше задавать отдельно от `API_PASS`. Если `JWT_SECRET` не задан, сервис подпишет JWT через `API_PASS`, но для продакшена это менее удобно при ротации пароля.
|
||||||
|
|
||||||
|
Настройки, измененные через UI, сохраняются в `data/config.json` и перекрывают значения из `.env`. Сетевые параметры `PORT` и `HOST` применятся после перезапуска процесса.
|
||||||
|
|
||||||
## Timeweb Cloud
|
## Timeweb Cloud
|
||||||
|
|
||||||
@ -94,3 +120,5 @@ Content-Type: application/json
|
|||||||
Поле `email` обязательно: на этот адрес сервис отправит письмо со ссылкой на чек после успешного создания чека в ФНС.
|
Поле `email` обязательно: на этот адрес сервис отправит письмо со ссылкой на чек после успешного создания чека в ФНС.
|
||||||
|
|
||||||
Если чек создан в ФНС, но письмо клиенту не отправилось, API вернет `success: true`, `receiptCreated: true`, `emailSent: false`, `receiptId`, `printLink` и `technicalError`. Ошибка отправки сохранится в `error.json`.
|
Если чек создан в ФНС, но письмо клиенту не отправилось, API вернет `success: true`, `receiptCreated: true`, `emailSent: false`, `receiptId`, `printLink` и `technicalError`. Ошибка отправки сохранится в `error.json`.
|
||||||
|
|
||||||
|
Все успешно созданные чеки сохраняются в локальный журнал `data/receipts.json`, чтобы в UI была связь между чеком, email пользователя и позициями заказа. Синхронизация с ФНС подтягивает последние чеки из кабинета, но для старых чеков, созданных не этим сервисом, email пользователя может быть неизвестен.
|
||||||
|
|||||||
304
public/app.js
Normal file
304
public/app.js
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
const state = {
|
||||||
|
token: localStorage.getItem("fnsAdminToken") || "",
|
||||||
|
tokenExpiresAt: Number(localStorage.getItem("fnsAdminTokenExpiresAt") || 0),
|
||||||
|
receipts: [],
|
||||||
|
errors: [],
|
||||||
|
config: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const $ = selector => document.querySelector(selector);
|
||||||
|
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
const result = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.token) {
|
||||||
|
result.Authorization = `Bearer ${state.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...headers(),
|
||||||
|
...(options.headers || {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || data.technicalError?.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToken(auth) {
|
||||||
|
state.token = auth.token;
|
||||||
|
state.tokenExpiresAt = auth.expiresAt;
|
||||||
|
localStorage.setItem("fnsAdminToken", state.token);
|
||||||
|
localStorage.setItem("fnsAdminTokenExpiresAt", String(state.tokenExpiresAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearToken() {
|
||||||
|
state.token = "";
|
||||||
|
state.tokenExpiresAt = 0;
|
||||||
|
localStorage.removeItem("fnsAdminToken");
|
||||||
|
localStorage.removeItem("fnsAdminTokenExpiresAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenLooksFresh() {
|
||||||
|
return Boolean(state.token && state.tokenExpiresAt * 1000 > Date.now() + 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(password) {
|
||||||
|
const auth = await api("/admin/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
saveToken(auth);
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(message) {
|
||||||
|
const el = $("#toast");
|
||||||
|
el.textContent = message;
|
||||||
|
el.classList.remove("hidden");
|
||||||
|
clearTimeout(toast.timer);
|
||||||
|
toast.timer = setTimeout(() => el.classList.add("hidden"), 4200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(value) {
|
||||||
|
return (Number(value) || 0).toLocaleString("ru-RU", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiptText(receipt) {
|
||||||
|
const itemText = (receipt.items || [])
|
||||||
|
.map(item => `${item.id || ""} ${item.name || item.title || ""}`)
|
||||||
|
.join(" ");
|
||||||
|
return `${receipt.email || ""} ${receipt.receiptId || ""} ${itemText}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReceipts() {
|
||||||
|
const query = $("#receipt-search").value.trim().toLowerCase();
|
||||||
|
const filtered = state.receipts.filter(receipt => receiptText(receipt).includes(query));
|
||||||
|
const total = filtered.reduce((sum, receipt) => sum + (Number(receipt.amount) || Number(receipt.totalAmount) || 0), 0);
|
||||||
|
|
||||||
|
$("#metric-count").textContent = filtered.length;
|
||||||
|
$("#metric-total").textContent = formatMoney(total);
|
||||||
|
$("#metric-errors").textContent = state.errors.length;
|
||||||
|
|
||||||
|
$("#receipts-body").innerHTML = filtered.map(receipt => {
|
||||||
|
const amount = receipt.amount || receipt.totalAmount || 0;
|
||||||
|
const emailBadge = receipt.emailSent === true
|
||||||
|
? '<span class="badge ok">отправлено</span>'
|
||||||
|
: receipt.emailSent === false
|
||||||
|
? '<span class="badge bad">ошибка</span>'
|
||||||
|
: '<span class="badge warn">нет данных</span>';
|
||||||
|
const source = receipt.syncedFromFns ? "ФНС" : "локально";
|
||||||
|
const link = receipt.printLink
|
||||||
|
? `<a href="${receipt.printLink}" target="_blank" rel="noreferrer">открыть</a>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${formatDate(receipt.createdAt || receipt.operationTime)}</td>
|
||||||
|
<td>${receipt.email || '<span class="muted">неизвестно</span>'}</td>
|
||||||
|
<td>
|
||||||
|
<div>${receipt.receiptId || '<span class="muted">нет ID</span>'}</div>
|
||||||
|
<div class="muted">${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}</div>
|
||||||
|
</td>
|
||||||
|
<td>${formatMoney(amount)} ₽</td>
|
||||||
|
<td>${emailBadge}</td>
|
||||||
|
<td><span class="badge">${source}</span></td>
|
||||||
|
<td>${link}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join("") || '<tr><td colspan="7" class="muted">Чеков пока нет</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSettings() {
|
||||||
|
$("#settings-list").innerHTML = state.config.map(item => {
|
||||||
|
const type = item.secret ? "password" : item.key.includes("PORT") || item.key.includes("TIMEOUT") ? "number" : "text";
|
||||||
|
const placeholder = item.secret && item.configured ? "сохранено, введите новое значение для замены" : "";
|
||||||
|
return `
|
||||||
|
<div class="setting">
|
||||||
|
<label>
|
||||||
|
<span>${item.key}</span>
|
||||||
|
<span class="source">${item.source}${item.configured ? "" : " / пусто"}</span>
|
||||||
|
</label>
|
||||||
|
<input name="${item.key}" type="${type}" value="${item.value || ""}" placeholder="${placeholder}">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReceipts() {
|
||||||
|
const data = await api("/admin/api/receipts");
|
||||||
|
state.receipts = data.receipts || [];
|
||||||
|
state.errors = data.errors || [];
|
||||||
|
renderReceipts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
const data = await api("/admin/api/config");
|
||||||
|
state.config = data.config || [];
|
||||||
|
renderSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
if (!tokenLooksFresh()) {
|
||||||
|
clearToken();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api("/admin/api/session", { method: "POST", body: "{}" });
|
||||||
|
$("#login-form").classList.add("hidden");
|
||||||
|
$("#session-actions").classList.remove("hidden");
|
||||||
|
await Promise.all([loadReceipts(), loadConfig()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItemRow(item = {}) {
|
||||||
|
const template = $("#item-row-template").content.cloneNode(true);
|
||||||
|
const row = template.querySelector(".item-row");
|
||||||
|
row.querySelector('[name="id"]').value = item.id || "";
|
||||||
|
row.querySelector('[name="name"]').value = item.name || "";
|
||||||
|
row.querySelector('[name="price"]').value = item.price || "";
|
||||||
|
row.querySelector('[name="quantity"]').value = item.quantity || 1;
|
||||||
|
row.querySelector(".remove-item").addEventListener("click", () => {
|
||||||
|
row.remove();
|
||||||
|
updateCreateTotal();
|
||||||
|
});
|
||||||
|
row.addEventListener("input", updateCreateTotal);
|
||||||
|
$("#items-list").append(row);
|
||||||
|
updateCreateTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentItems() {
|
||||||
|
return $$("#items-list .item-row").map(row => ({
|
||||||
|
id: row.querySelector('[name="id"]').value.trim(),
|
||||||
|
name: row.querySelector('[name="name"]').value.trim(),
|
||||||
|
price: Number(row.querySelector('[name="price"]').value),
|
||||||
|
quantity: Number(row.querySelector('[name="quantity"]').value) || 1
|
||||||
|
})).filter(item => item.name && item.price > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCreateTotal() {
|
||||||
|
const total = currentItems().reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||||
|
$("#create-total").textContent = formatMoney(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showView(name) {
|
||||||
|
$$(".tab").forEach(tab => tab.classList.toggle("active", tab.dataset.view === name));
|
||||||
|
$$(".view").forEach(view => view.classList.toggle("active", view.id === `view-${name}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
$$(".tab").forEach(tab => tab.addEventListener("click", () => showView(tab.dataset.view)));
|
||||||
|
|
||||||
|
$("#login-form").addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await login($("#admin-password").value);
|
||||||
|
$("#admin-password").value = "";
|
||||||
|
await bootstrap();
|
||||||
|
toast("Вход выполнен");
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#logout-button").addEventListener("click", () => {
|
||||||
|
clearToken();
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#receipt-search").addEventListener("input", renderReceipts);
|
||||||
|
$("#reload-receipts").addEventListener("click", () => loadReceipts().then(() => toast("Чеки обновлены")).catch(err => toast(err.message)));
|
||||||
|
$("#sync-fns").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const result = await api("/admin/api/receipts/sync", { method: "POST", body: "{}" });
|
||||||
|
await loadReceipts();
|
||||||
|
toast(`Синхронизировано из ФНС: ${result.synced}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#add-item").addEventListener("click", () => addItemRow());
|
||||||
|
$("#create-form").addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const email = new FormData(event.currentTarget).get("email");
|
||||||
|
const items = currentItems();
|
||||||
|
if (!items.length) {
|
||||||
|
toast("Добавьте хотя бы одну позицию с ценой");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await api("/api/v1/create-receipt", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, items })
|
||||||
|
});
|
||||||
|
await loadReceipts();
|
||||||
|
toast(result.emailSent ? "Чек создан и отправлен" : "Чек создан, но письмо не ушло");
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#settings-form").addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const values = Object.fromEntries(new FormData(event.currentTarget).entries());
|
||||||
|
const nextPassword = values.API_PASS;
|
||||||
|
const rotatesJwtSecret = Boolean(values.JWT_SECRET);
|
||||||
|
try {
|
||||||
|
const data = await api("/admin/api/config", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ values })
|
||||||
|
});
|
||||||
|
if (nextPassword) {
|
||||||
|
await login(nextPassword);
|
||||||
|
} else if (rotatesJwtSecret) {
|
||||||
|
clearToken();
|
||||||
|
$("#login-form").classList.remove("hidden");
|
||||||
|
$("#session-actions").classList.add("hidden");
|
||||||
|
}
|
||||||
|
state.config = data.config || [];
|
||||||
|
renderSettings();
|
||||||
|
toast(rotatesJwtSecret && !nextPassword ? "Настройки сохранены, войдите заново" : "Настройки сохранены");
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#check-health").addEventListener("click", async () => {
|
||||||
|
$("#health-output").textContent = JSON.stringify(await fetch("/health/deep").then(r => r.json()), null, 2);
|
||||||
|
});
|
||||||
|
$("#check-smtp").addEventListener("click", async () => {
|
||||||
|
$("#health-output").textContent = JSON.stringify(await fetch("/health/smtp").then(r => r.json()), null, 2);
|
||||||
|
});
|
||||||
|
$("#check-fns-user").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
$("#health-output").textContent = JSON.stringify(await api("/admin/api/fns-user"), null, 2);
|
||||||
|
} catch (err) {
|
||||||
|
$("#health-output").textContent = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
addItemRow();
|
||||||
|
bootstrap().catch(err => toast(err.message));
|
||||||
121
public/index.html
Normal file
121
public/index.html
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>FNS Receipt Service</title>
|
||||||
|
<link rel="stylesheet" href="/admin/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>FNS Receipt Service</h1>
|
||||||
|
<p id="subtitle">Админ-панель чеков и настроек</p>
|
||||||
|
</div>
|
||||||
|
<form id="login-form" class="login">
|
||||||
|
<input id="admin-password" type="password" autocomplete="current-password" placeholder="API_PASS">
|
||||||
|
<button type="submit">Войти</button>
|
||||||
|
</form>
|
||||||
|
<div id="session-actions" class="session-actions hidden">
|
||||||
|
<span id="session-status">Подключено</span>
|
||||||
|
<button id="logout-button" type="button">Выйти</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs" aria-label="Разделы">
|
||||||
|
<button class="tab active" type="button" data-view="receipts">Чеки</button>
|
||||||
|
<button class="tab" type="button" data-view="create">Создать чек</button>
|
||||||
|
<button class="tab" type="button" data-view="settings">Настройки</button>
|
||||||
|
<button class="tab" type="button" data-view="health">Диагностика</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="view-receipts" class="view active">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search">
|
||||||
|
<input id="receipt-search" type="search" placeholder="Поиск по email, ID чека или позиции">
|
||||||
|
</div>
|
||||||
|
<button id="reload-receipts" type="button">Обновить</button>
|
||||||
|
<button id="sync-fns" type="button">Синхронизировать с ФНС</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metrics">
|
||||||
|
<div><span id="metric-count">0</span><small>чеков</small></div>
|
||||||
|
<div><span id="metric-total">0.00</span><small>рублей</small></div>
|
||||||
|
<div><span id="metric-errors">0</span><small>ошибок</small></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Чек</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th>Письмо</th>
|
||||||
|
<th>Источник</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="receipts-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="view-create" class="view">
|
||||||
|
<form id="create-form" class="panel form-grid">
|
||||||
|
<label>
|
||||||
|
Email пользователя
|
||||||
|
<input name="email" type="email" required placeholder="client@example.com">
|
||||||
|
</label>
|
||||||
|
<div class="items-head">
|
||||||
|
<strong>Позиции</strong>
|
||||||
|
<button id="add-item" type="button">Добавить позицию</button>
|
||||||
|
</div>
|
||||||
|
<div id="items-list" class="items-list"></div>
|
||||||
|
<div class="form-footer">
|
||||||
|
<span>Итого: <strong id="create-total">0.00</strong> ₽</span>
|
||||||
|
<button type="submit">Создать чек</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="view-settings" class="view">
|
||||||
|
<form id="settings-form" class="panel">
|
||||||
|
<div class="settings-head">
|
||||||
|
<div>
|
||||||
|
<h2>Параметры сервиса</h2>
|
||||||
|
<p>Пустое секретное поле оставляет текущее значение без изменений.</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
<div id="settings-list" class="settings-list"></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="view-health" class="view">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="check-health" type="button">Проверить сервис</button>
|
||||||
|
<button id="check-smtp" type="button">Проверить SMTP</button>
|
||||||
|
<button id="check-fns-user" type="button">Профиль ФНС</button>
|
||||||
|
</div>
|
||||||
|
<pre id="health-output" class="output">Нет данных</pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<template id="item-row-template">
|
||||||
|
<div class="item-row">
|
||||||
|
<input name="id" placeholder="ID заказа">
|
||||||
|
<input name="name" placeholder="Название" required>
|
||||||
|
<input name="price" type="number" min="0" step="0.01" placeholder="Цена" required>
|
||||||
|
<input name="quantity" type="number" min="1" step="1" value="1" placeholder="Кол-во">
|
||||||
|
<button type="button" class="remove-item" aria-label="Удалить позицию">x</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="/admin/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
670
public/openapi.json
Normal file
670
public/openapi.json
Normal file
@ -0,0 +1,670 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"info": {
|
||||||
|
"title": "FNS Receipt Service API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "API сервиса создания чеков ФНС, админ-панели, настроек и диагностики."
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "/",
|
||||||
|
"description": "Current host"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "Service",
|
||||||
|
"description": "Состояние сервиса"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Receipts",
|
||||||
|
"description": "Создание чеков"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Admin",
|
||||||
|
"description": "Админские методы UI"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"AdminBearer": {
|
||||||
|
"type": "http",
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "JWT",
|
||||||
|
"description": "JWT из POST /admin/api/login"
|
||||||
|
},
|
||||||
|
"AdminPassword": {
|
||||||
|
"type": "apiKey",
|
||||||
|
"in": "header",
|
||||||
|
"name": "x-admin-password",
|
||||||
|
"description": "Legacy: значение API_PASS"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"Health": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"examples": [
|
||||||
|
"ok"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Item": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"price"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"examples": [
|
||||||
|
"order-1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"examples": [
|
||||||
|
"Услуга"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"price": {
|
||||||
|
"type": "number",
|
||||||
|
"examples": [
|
||||||
|
1000
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"quantity": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 1,
|
||||||
|
"examples": [
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CreateReceiptRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"email",
|
||||||
|
"items"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"api_pass": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Legacy-доступ для внешних интеграций. В UI вместо него используется Bearer JWT."
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email",
|
||||||
|
"examples": [
|
||||||
|
"client@example.com"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Item"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CreateReceiptResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"receiptCreated": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"emailSent": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"receiptId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"printLink": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"technicalError": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Receipt": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"receiptId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"printLink": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"emailSent": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Item"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"ConfigItem": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"configured": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"env",
|
||||||
|
"ui"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ErrorResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"error": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"technicalError": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LoginRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"password"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Значение API_PASS"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LoginResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"expiresIn": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Service"
|
||||||
|
],
|
||||||
|
"summary": "Информация о сервисе",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Сервис доступен",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"service": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/health": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Service"
|
||||||
|
],
|
||||||
|
"summary": "Быстрая проверка состояния",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Health"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/health/deep": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Service"
|
||||||
|
],
|
||||||
|
"summary": "Проверка SMTP и ФНС",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Результат диагностики",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"connect_to_fns": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"smtp": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/health/smtp": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Service"
|
||||||
|
],
|
||||||
|
"summary": "Проверка SMTP",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "SMTP доступен"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Ошибка SMTP",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/create-receipt": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Receipts"
|
||||||
|
],
|
||||||
|
"summary": "Создать чек в ФНС и отправить email",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CreateReceiptRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Чек создан",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CreateReceiptResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Неверные данные"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Неверный api_pass"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Ошибка создания чека",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"AdminBearer": []
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/api/session": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Admin"
|
||||||
|
],
|
||||||
|
"summary": "Проверить пароль админ-панели",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"AdminBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Пароль принят"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Неверный пароль"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/api/config": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin"
|
||||||
|
],
|
||||||
|
"summary": "Получить настройки сервиса",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"AdminBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Настройки",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"config": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ConfigItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"Admin"
|
||||||
|
],
|
||||||
|
"summary": "Обновить настройки сервиса",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"AdminBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"values": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Настройки сохранены"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/api/receipts": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin"
|
||||||
|
],
|
||||||
|
"summary": "Получить локальный журнал чеков и ошибок",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"AdminBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Чеки и ошибки",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"receipts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Receipt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/api/receipts/sync": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Admin"
|
||||||
|
],
|
||||||
|
"summary": "Синхронизировать последние чеки из ФНС",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"AdminBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Синхронизация выполнена",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"synced": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Ошибка синхронизации"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/api/fns-user": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin"
|
||||||
|
],
|
||||||
|
"summary": "Получить профиль ФНС",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"AdminBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Профиль ФНС",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"user": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Ошибка ФНС"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/openapi.json": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Service"
|
||||||
|
],
|
||||||
|
"summary": "OpenAPI JSON",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Спецификация OpenAPI"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/swagger": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Service"
|
||||||
|
],
|
||||||
|
"summary": "Swagger UI",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Swagger UI HTML"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/api/login": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Admin"
|
||||||
|
],
|
||||||
|
"summary": "Войти в админ-панель и получить JWT",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/LoginRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "JWT создан",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/LoginResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Неверный пароль"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
355
public/styles.css
Normal file
355
public/styles.css
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f4f7f6;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #eef3f1;
|
||||||
|
--text: #16211e;
|
||||||
|
--muted: #63716d;
|
||||||
|
--line: #d9e1de;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-2: #c2410c;
|
||||||
|
--ok: #15803d;
|
||||||
|
--warn: #b45309;
|
||||||
|
--bad: #b91c1c;
|
||||||
|
--shadow: 0 12px 30px rgba(28, 45, 40, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="submit"],
|
||||||
|
#sync-fns {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: 2px solid rgba(15, 118, 110, 0.2);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr minmax(280px, 420px);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 22px 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#subtitle,
|
||||||
|
.settings-head p {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login,
|
||||||
|
.session-actions,
|
||||||
|
.toolbar,
|
||||||
|
.form-footer,
|
||||||
|
.settings-head {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom-color: var(--surface);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 22px 28px 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics div,
|
||||||
|
.panel,
|
||||||
|
.table-wrap,
|
||||||
|
.output {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics div {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics span {
|
||||||
|
display: block;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 860px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
td a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.ok {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.warn {
|
||||||
|
background: #ffedd5;
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.bad {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(90px, 0.7fr) minmax(180px, 2fr) minmax(90px, 0.8fr) minmax(80px, 0.6fr) 38px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-item {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(240px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
min-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 22px;
|
||||||
|
bottom: 22px;
|
||||||
|
max-width: min(420px, calc(100vw - 44px));
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--text);
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.topbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
padding-inline: 18px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar,
|
||||||
|
.login,
|
||||||
|
.form-footer,
|
||||||
|
.settings-head {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics,
|
||||||
|
.settings-list,
|
||||||
|
.item-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
public/swagger.html
Normal file
30
public/swagger.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>FNS Receipt Service API</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = () => {
|
||||||
|
window.ui = SwaggerUIBundle({
|
||||||
|
url: "/openapi.json",
|
||||||
|
dom_id: "#swagger-ui",
|
||||||
|
deepLinking: true,
|
||||||
|
persistAuthorization: true,
|
||||||
|
displayRequestDuration: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
526
server.js
526
server.js
@ -5,6 +5,7 @@ import dotenv from "dotenv";
|
|||||||
import pkg from "lknpd-nalog-api";
|
import pkg from "lknpd-nalog-api";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import crypto from "crypto";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@ -17,42 +18,119 @@ const __dirname = path.dirname(__filename);
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: "1mb" }));
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
|
||||||
const PORT = process.env.PORT || 4000;
|
|
||||||
const HOST = process.env.HOST || "0.0.0.0";
|
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
const FNS_TIMEOUT_MS = Number(process.env.FNS_TIMEOUT_MS || 30000);
|
const DATA_DIR = path.join(__dirname, "data");
|
||||||
const SMTP_TIMEOUT_MS = Number(process.env.SMTP_TIMEOUT_MS || 15000);
|
|
||||||
const ERROR_FILE = path.join(__dirname, "error.json");
|
const ERROR_FILE = path.join(__dirname, "error.json");
|
||||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL;
|
const RECEIPTS_FILE = path.join(DATA_DIR, "receipts.json");
|
||||||
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
|
const CONFIG_FILE = path.join(DATA_DIR, "config.json");
|
||||||
const SMTP_SECURE = process.env.SMTP_SECURE
|
const PUBLIC_DIR = path.join(__dirname, "public");
|
||||||
? process.env.SMTP_SECURE === "true"
|
const CONFIG_KEYS = [
|
||||||
: SMTP_PORT === 465;
|
"INN",
|
||||||
|
"PASSWORD",
|
||||||
const transporter = nodemailer.createTransport({
|
"APPNAME",
|
||||||
host: process.env.SMTP_HOST,
|
"ADMIN_EMAIL",
|
||||||
port: SMTP_PORT,
|
"SMTP_HOST",
|
||||||
secure: SMTP_SECURE,
|
"SMTP_PORT",
|
||||||
auth: {
|
"SMTP_SECURE",
|
||||||
user: process.env.SMTP_USER,
|
"SMTP_USER",
|
||||||
pass: process.env.SMTP_PASS
|
"SMTP_PASS",
|
||||||
},
|
"SMTP_MAIL_FROM",
|
||||||
connectionTimeout: SMTP_TIMEOUT_MS,
|
"API_PASS",
|
||||||
greetingTimeout: SMTP_TIMEOUT_MS,
|
"JWT_SECRET",
|
||||||
socketTimeout: SMTP_TIMEOUT_MS
|
"ADMIN_SESSION_HOURS",
|
||||||
});
|
"PORT",
|
||||||
|
"HOST",
|
||||||
|
"FNS_TIMEOUT_MS",
|
||||||
|
"SMTP_TIMEOUT_MS"
|
||||||
|
];
|
||||||
|
const SECRET_KEYS = new Set(["PASSWORD", "SMTP_PASS", "API_PASS", "JWT_SECRET"]);
|
||||||
|
|
||||||
let nalogApi;
|
let nalogApi;
|
||||||
|
let runtimeConfig = {};
|
||||||
|
let transporter;
|
||||||
|
|
||||||
|
function getConfig(key, fallback = "") {
|
||||||
|
return runtimeConfig[key] ?? process.env[key] ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumberConfig(key, fallback) {
|
||||||
|
return Number(getConfig(key, fallback)) || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBooleanConfig(key, fallback = false) {
|
||||||
|
const value = getConfig(key, fallback ? "true" : "false");
|
||||||
|
return value === true || value === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSmtpPort() {
|
||||||
|
return getNumberConfig("SMTP_PORT", 587);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSmtpSecure() {
|
||||||
|
const value = getConfig("SMTP_SECURE", "");
|
||||||
|
return value === "" ? getSmtpPort() === 465 : getBooleanConfig("SMTP_SECURE");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFnsTimeoutMs() {
|
||||||
|
return getNumberConfig("FNS_TIMEOUT_MS", 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSmtpTimeoutMs() {
|
||||||
|
return getNumberConfig("SMTP_TIMEOUT_MS", 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdminSessionSeconds() {
|
||||||
|
return Math.max(1, getNumberConfig("ADMIN_SESSION_HOURS", 12)) * 60 * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTransporter() {
|
||||||
|
const smtpTimeoutMs = getSmtpTimeoutMs();
|
||||||
|
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: getConfig("SMTP_HOST"),
|
||||||
|
port: getSmtpPort(),
|
||||||
|
secure: getSmtpSecure(),
|
||||||
|
auth: {
|
||||||
|
user: getConfig("SMTP_USER"),
|
||||||
|
pass: getConfig("SMTP_PASS")
|
||||||
|
},
|
||||||
|
connectionTimeout: smtpTimeoutMs,
|
||||||
|
greetingTimeout: smtpTimeoutMs,
|
||||||
|
socketTimeout: smtpTimeoutMs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile(filePath, fallback) {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(filePath, "utf8");
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (err) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonFile(filePath, value) {
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(value, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRuntimeConfig() {
|
||||||
|
const savedConfig = await readJsonFile(CONFIG_FILE, {});
|
||||||
|
runtimeConfig = savedConfig && typeof savedConfig === "object" && !Array.isArray(savedConfig)
|
||||||
|
? savedConfig
|
||||||
|
: {};
|
||||||
|
transporter = createTransporter();
|
||||||
|
}
|
||||||
|
|
||||||
function getNalogApi() {
|
function getNalogApi() {
|
||||||
if (!process.env.INN || !process.env.PASSWORD) {
|
if (!getConfig("INN") || !getConfig("PASSWORD")) {
|
||||||
throw new Error("INN and PASSWORD environment variables are required");
|
throw new Error("INN and PASSWORD environment variables are required");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nalogApi) {
|
if (!nalogApi) {
|
||||||
nalogApi = new NalogApi({
|
nalogApi = new NalogApi({
|
||||||
inn: process.env.INN,
|
inn: getConfig("INN"),
|
||||||
password: process.env.PASSWORD
|
password: getConfig("PASSWORD")
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,13 +169,113 @@ function formatTechnicalError(err) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(value) {
|
||||||
|
return Buffer.from(value)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/=/g, "")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlDecode(value) {
|
||||||
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), "=");
|
||||||
|
return Buffer.from(padded, "base64").toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqual(a, b) {
|
||||||
|
const first = Buffer.from(String(a));
|
||||||
|
const second = Buffer.from(String(b));
|
||||||
|
return first.length === second.length && crypto.timingSafeEqual(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJwtSecret() {
|
||||||
|
return getConfig("JWT_SECRET") || getConfig("API_PASS");
|
||||||
|
}
|
||||||
|
|
||||||
|
function signJwt(payload) {
|
||||||
|
const secret = getJwtSecret();
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error("JWT_SECRET or API_PASS is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedHeader = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||||
|
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac("sha256", secret)
|
||||||
|
.update(`${encodedHeader}.${encodedPayload}`)
|
||||||
|
.digest("base64")
|
||||||
|
.replace(/=/g, "")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_");
|
||||||
|
|
||||||
|
return `${encodedHeader}.${encodedPayload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyJwt(token) {
|
||||||
|
if (!token || typeof token !== "string") return null;
|
||||||
|
|
||||||
|
const [encodedHeader, encodedPayload, signature] = token.split(".");
|
||||||
|
if (!encodedHeader || !encodedPayload || !signature) return null;
|
||||||
|
|
||||||
|
const secret = getJwtSecret();
|
||||||
|
if (!secret) return null;
|
||||||
|
|
||||||
|
const expectedSignature = crypto
|
||||||
|
.createHmac("sha256", secret)
|
||||||
|
.update(`${encodedHeader}.${encodedPayload}`)
|
||||||
|
.digest("base64")
|
||||||
|
.replace(/=/g, "")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_");
|
||||||
|
|
||||||
|
if (!safeEqual(signature, expectedSignature)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(base64UrlDecode(encodedPayload));
|
||||||
|
if (!payload.exp || payload.exp < Math.floor(Date.now() / 1000)) return null;
|
||||||
|
if (payload.sub !== "admin") return null;
|
||||||
|
return payload;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAdminToken() {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const expiresIn = getAdminSessionSeconds();
|
||||||
|
const expiresAt = now + expiresIn;
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: signJwt({
|
||||||
|
sub: "admin",
|
||||||
|
role: "admin",
|
||||||
|
iat: now,
|
||||||
|
exp: expiresAt
|
||||||
|
}),
|
||||||
|
expiresAt,
|
||||||
|
expiresIn
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBearerToken(req) {
|
||||||
|
const authorization = req.get("authorization") || "";
|
||||||
|
const [scheme, token] = authorization.split(" ");
|
||||||
|
return scheme?.toLowerCase() === "bearer" ? token : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyAdminPassword(password) {
|
||||||
|
const expectedPassword = getConfig("API_PASS");
|
||||||
|
return Boolean(expectedPassword && password && safeEqual(password, expectedPassword));
|
||||||
|
}
|
||||||
|
|
||||||
function smtpConfigForResponse() {
|
function smtpConfigForResponse() {
|
||||||
return {
|
return {
|
||||||
host: process.env.SMTP_HOST,
|
host: getConfig("SMTP_HOST"),
|
||||||
port: SMTP_PORT,
|
port: getSmtpPort(),
|
||||||
secure: SMTP_SECURE,
|
secure: getSmtpSecure(),
|
||||||
user: process.env.SMTP_USER,
|
user: getConfig("SMTP_USER"),
|
||||||
from: process.env.SMTP_MAIL_FROM
|
from: getConfig("SMTP_MAIL_FROM")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,7 +301,7 @@ async function createReceiptWithRetry(income, retries = MAX_RETRIES) {
|
|||||||
try {
|
try {
|
||||||
return await withTimeout(
|
return await withTimeout(
|
||||||
getNalogApi().addIncome(income),
|
getNalogApi().addIncome(income),
|
||||||
FNS_TIMEOUT_MS,
|
getFnsTimeoutMs(),
|
||||||
"FNS receipt creation"
|
"FNS receipt creation"
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -160,6 +338,135 @@ async function saveToErrorFile(errorData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveReceipt(receiptData) {
|
||||||
|
const receipts = await readJsonFile(RECEIPTS_FILE, []);
|
||||||
|
const nextReceipts = Array.isArray(receipts) ? receipts : [];
|
||||||
|
const existingIndex = nextReceipts.findIndex(item => item.receiptId === receiptData.receiptId);
|
||||||
|
const normalizedReceipt = {
|
||||||
|
...receiptData,
|
||||||
|
createdAt: receiptData.createdAt || new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
nextReceipts[existingIndex] = {
|
||||||
|
...nextReceipts[existingIndex],
|
||||||
|
...normalizedReceipt
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
nextReceipts.unshift(normalizedReceipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeJsonFile(RECEIPTS_FILE, nextReceipts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
if (!getConfig("API_PASS")) {
|
||||||
|
return res.status(500).json({ error: "API_PASS is not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenPayload = verifyJwt(getBearerToken(req));
|
||||||
|
if (tokenPayload) {
|
||||||
|
req.admin = tokenPayload;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyAdminPassword(req.get("x-admin-password") || req.body?.api_pass)) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReceiptAccess(req) {
|
||||||
|
return verifyJwt(getBearerToken(req)) || verifyAdminPassword(req.body?.api_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicConfig() {
|
||||||
|
return CONFIG_KEYS.map(key => ({
|
||||||
|
key,
|
||||||
|
value: SECRET_KEYS.has(key) ? "" : String(getConfig(key, "")),
|
||||||
|
configured: Boolean(getConfig(key, "")),
|
||||||
|
secret: SECRET_KEYS.has(key),
|
||||||
|
source: Object.prototype.hasOwnProperty.call(runtimeConfig, key) ? "ui" : "env"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function receiptPrintLink(receiptId) {
|
||||||
|
return `https://lknpd.nalog.ru/api/v1/receipt/${getConfig("INN")}/${receiptId}/print`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFnsIncome(item) {
|
||||||
|
const receiptId = item.receiptUuid || item.uuid || item.approvedReceiptUuid || item.receiptId || item.id;
|
||||||
|
const amount = Number(item.totalAmount || item.amount || item.incomeInfo?.totalAmount || 0);
|
||||||
|
return {
|
||||||
|
source: "fns",
|
||||||
|
receiptId,
|
||||||
|
email: "",
|
||||||
|
amount,
|
||||||
|
status: item.cancelTime || item.cancellationInfo ? "cancelled" : "created",
|
||||||
|
emailSent: null,
|
||||||
|
createdAt: item.operationTime || item.requestTime || item.createdAt || null,
|
||||||
|
printLink: receiptId ? receiptPrintLink(receiptId) : "",
|
||||||
|
items: item.services || [],
|
||||||
|
raw: item
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFnsReceipts({ offset = 0, limit = 50 } = {}) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
offset: String(offset),
|
||||||
|
limit: String(Math.min(Number(limit) || 50, 50)),
|
||||||
|
sortBy: "operation_time:desc"
|
||||||
|
});
|
||||||
|
const data = await withTimeout(
|
||||||
|
getNalogApi().callMethod(`incomes?${params.toString()}`),
|
||||||
|
getFnsTimeoutMs(),
|
||||||
|
"FNS income list"
|
||||||
|
);
|
||||||
|
const items = data.items || data.content || data.incomes || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: Array.isArray(items) ? items.map(normalizeFnsIncome) : [],
|
||||||
|
total: data.total || data.totalCount || items.length || 0,
|
||||||
|
hasMore: Boolean(data.hasMore || data.has_more)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncFnsReceipts() {
|
||||||
|
const fnsReceipts = await getFnsReceipts();
|
||||||
|
const localReceipts = await readJsonFile(RECEIPTS_FILE, []);
|
||||||
|
const merged = Array.isArray(localReceipts) ? [...localReceipts] : [];
|
||||||
|
|
||||||
|
for (const fnsReceipt of fnsReceipts.items) {
|
||||||
|
if (!fnsReceipt.receiptId) continue;
|
||||||
|
const existingIndex = merged.findIndex(item => item.receiptId === fnsReceipt.receiptId);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
merged[existingIndex] = {
|
||||||
|
...fnsReceipt,
|
||||||
|
...merged[existingIndex],
|
||||||
|
fns: fnsReceipt.raw,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
merged.push({
|
||||||
|
...fnsReceipt,
|
||||||
|
status: fnsReceipt.status || "created",
|
||||||
|
syncedFromFns: true,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
|
||||||
|
await writeJsonFile(RECEIPTS_FILE, merged);
|
||||||
|
|
||||||
|
return {
|
||||||
|
synced: fnsReceipts.items.length,
|
||||||
|
total: merged.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function notifyAdmin(errorData) {
|
async function notifyAdmin(errorData) {
|
||||||
try {
|
try {
|
||||||
const html = `
|
const html = `
|
||||||
@ -185,21 +492,31 @@ async function notifyAdmin(errorData) {
|
|||||||
|
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
transporter.sendMail({
|
transporter.sendMail({
|
||||||
from: process.env.SMTP_MAIL_FROM,
|
from: getConfig("SMTP_MAIL_FROM"),
|
||||||
to: ADMIN_EMAIL,
|
to: getConfig("ADMIN_EMAIL"),
|
||||||
subject: `Ошибка создания чека ${process.env.APPNAME}`,
|
subject: `Ошибка создания чека ${getConfig("APPNAME")}`,
|
||||||
html
|
html
|
||||||
}),
|
}),
|
||||||
SMTP_TIMEOUT_MS,
|
getSmtpTimeoutMs(),
|
||||||
"Admin email sending"
|
"Admin email sending"
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Администратор ${ADMIN_EMAIL} уведомлен об ошибке`);
|
console.log(`Администратор ${getConfig("ADMIN_EMAIL")} уведомлен об ошибке`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Не удалось отправить уведомление администратору:", err);
|
console.error("Не удалось отправить уведомление администратору:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.use("/admin", express.static(PUBLIC_DIR));
|
||||||
|
|
||||||
|
app.get("/openapi.json", (req, res) => {
|
||||||
|
res.sendFile(path.join(PUBLIC_DIR, "openapi.json"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/swagger", (req, res) => {
|
||||||
|
res.sendFile(path.join(PUBLIC_DIR, "swagger.html"));
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
service: "fns-receipt-service",
|
service: "fns-receipt-service",
|
||||||
@ -249,7 +566,7 @@ app.get("/health/smtp", async (req, res) => {
|
|||||||
const smtp = smtpConfigForResponse();
|
const smtp = smtpConfigForResponse();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkTcpConnection(process.env.SMTP_HOST, SMTP_PORT, SMTP_TIMEOUT_MS);
|
await checkTcpConnection(getConfig("SMTP_HOST"), getSmtpPort(), getSmtpTimeoutMs());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
status: "error",
|
status: "error",
|
||||||
@ -261,7 +578,7 @@ app.get("/health/smtp", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withTimeout(transporter.verify(), SMTP_TIMEOUT_MS, "SMTP health check");
|
await withTimeout(transporter.verify(), getSmtpTimeoutMs(), "SMTP health check");
|
||||||
res.json({ status: "ok", smtp: "ok", config: smtp });
|
res.json({ status: "ok", smtp: "ok", config: smtp });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@ -274,11 +591,101 @@ app.get("/health/smtp", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/admin/api/login", (req, res) => {
|
||||||
|
if (!getConfig("API_PASS")) {
|
||||||
|
return res.status(500).json({ error: "API_PASS is not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyAdminPassword(req.body?.password)) {
|
||||||
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
...createAdminToken()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/api/session", requireAdmin, (req, res) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
admin: req.admin || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/api/config", requireAdmin, (req, res) => {
|
||||||
|
res.json({
|
||||||
|
config: publicConfig(),
|
||||||
|
files: {
|
||||||
|
config: CONFIG_FILE,
|
||||||
|
receipts: RECEIPTS_FILE,
|
||||||
|
errors: ERROR_FILE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/admin/api/config", requireAdmin, async (req, res) => {
|
||||||
|
const values = req.body?.values || {};
|
||||||
|
const nextConfig = { ...runtimeConfig };
|
||||||
|
|
||||||
|
for (const key of CONFIG_KEYS) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(values, key)) continue;
|
||||||
|
const value = typeof values[key] === "string" ? values[key].trim() : values[key];
|
||||||
|
if (SECRET_KEYS.has(key) && value === "") continue;
|
||||||
|
if (value === "" || value === null || value === undefined) {
|
||||||
|
delete nextConfig[key];
|
||||||
|
} else {
|
||||||
|
nextConfig[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeConfig = nextConfig;
|
||||||
|
nalogApi = undefined;
|
||||||
|
transporter = createTransporter();
|
||||||
|
await writeJsonFile(CONFIG_FILE, runtimeConfig);
|
||||||
|
|
||||||
|
res.json({ success: true, config: publicConfig() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/api/receipts", requireAdmin, async (req, res) => {
|
||||||
|
const receipts = await readJsonFile(RECEIPTS_FILE, []);
|
||||||
|
const errors = await readJsonFile(ERROR_FILE, []);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
receipts: Array.isArray(receipts) ? receipts : [],
|
||||||
|
errors: Array.isArray(errors) ? errors : []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/api/receipts/sync", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await syncFnsReceipts();
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Не удалось синхронизировать чеки из ФНС",
|
||||||
|
technicalError: formatTechnicalError(err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/admin/api/fns-user", requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await withTimeout(getNalogApi().getUserInfo(), getFnsTimeoutMs(), "FNS user info");
|
||||||
|
res.json({ user });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Не удалось получить профиль ФНС",
|
||||||
|
technicalError: formatTechnicalError(err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/v1/create-receipt", async (req, res) => {
|
app.post("/api/v1/create-receipt", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { api_pass, email, items } = req.body;
|
const { email, items } = req.body;
|
||||||
|
|
||||||
if (api_pass !== process.env.API_PASS) {
|
if (!hasReceiptAccess(req)) {
|
||||||
return res.status(401).json({ error: "Unauthorized" });
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,14 +696,14 @@ app.post("/api/v1/create-receipt", async (req, res) => {
|
|||||||
const total = calculateTotal(items);
|
const total = calculateTotal(items);
|
||||||
|
|
||||||
const income = {
|
const income = {
|
||||||
name: `${process.env.APPNAME}`,
|
name: `${getConfig("APPNAME")}`,
|
||||||
amount: Number(total.toFixed(2)),
|
amount: Number(total.toFixed(2)),
|
||||||
quantity: 1
|
quantity: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
const receiptId = await createReceiptWithRetry(income);
|
const receiptId = await createReceiptWithRetry(income);
|
||||||
|
|
||||||
const printLink = `https://lknpd.nalog.ru/api/v1/receipt/${process.env.INN}/${receiptId}/print`;
|
const printLink = receiptPrintLink(receiptId);
|
||||||
|
|
||||||
const rows = items.map(i => {
|
const rows = items.map(i => {
|
||||||
const price = Number(i.price) || 0;
|
const price = Number(i.price) || 0;
|
||||||
@ -346,7 +753,7 @@ style="display:inline-block;background:#ffdd2d;padding:16px 36px;border-radius:4
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<p style="font-size:12px;color:#999;margin-top:24px;">
|
<p style="font-size:12px;color:#999;margin-top:24px;">
|
||||||
${process.env.APPNAME}
|
${getConfig("APPNAME")}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -361,12 +768,12 @@ ${process.env.APPNAME}
|
|||||||
try {
|
try {
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
transporter.sendMail({
|
transporter.sendMail({
|
||||||
from: process.env.SMTP_MAIL_FROM,
|
from: getConfig("SMTP_MAIL_FROM"),
|
||||||
to: email,
|
to: email,
|
||||||
subject: `Чек ${process.env.APPNAME}`,
|
subject: `Чек ${getConfig("APPNAME")}`,
|
||||||
html
|
html
|
||||||
}),
|
}),
|
||||||
SMTP_TIMEOUT_MS,
|
getSmtpTimeoutMs(),
|
||||||
"Client email sending"
|
"Client email sending"
|
||||||
);
|
);
|
||||||
} catch (emailErr) {
|
} catch (emailErr) {
|
||||||
@ -383,6 +790,16 @@ ${process.env.APPNAME}
|
|||||||
error: emailErr.message || "Не удалось отправить email клиенту",
|
error: emailErr.message || "Не удалось отправить email клиенту",
|
||||||
technicalError
|
technicalError
|
||||||
});
|
});
|
||||||
|
await saveReceipt({
|
||||||
|
receiptId,
|
||||||
|
email,
|
||||||
|
items,
|
||||||
|
amount: total,
|
||||||
|
printLink,
|
||||||
|
status: "created",
|
||||||
|
emailSent: false,
|
||||||
|
emailError: technicalError
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@ -395,6 +812,16 @@ ${process.env.APPNAME}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await saveReceipt({
|
||||||
|
receiptId,
|
||||||
|
email,
|
||||||
|
items,
|
||||||
|
amount: total,
|
||||||
|
printLink,
|
||||||
|
status: "created",
|
||||||
|
emailSent: true
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
receiptCreated: true,
|
receiptCreated: true,
|
||||||
@ -426,7 +853,14 @@ ${process.env.APPNAME}
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await loadRuntimeConfig();
|
||||||
|
|
||||||
|
const PORT = getConfig("PORT", 4000);
|
||||||
|
const HOST = getConfig("HOST", "0.0.0.0");
|
||||||
|
|
||||||
app.listen(PORT, HOST, () => {
|
app.listen(PORT, HOST, () => {
|
||||||
console.log(`✅ Сервер запущен: http://${HOST}:${PORT}`);
|
console.log(`✅ Сервер запущен: http://${HOST}:${PORT}`);
|
||||||
console.log(`📁 Файл ошибок: ${ERROR_FILE}`);
|
console.log(`📁 Файл ошибок: ${ERROR_FILE}`);
|
||||||
|
console.log(`🧾 Журнал чеков: ${RECEIPTS_FILE}`);
|
||||||
|
console.log(`⚙️ UI настройки: ${CONFIG_FILE}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user