commit
aeec8261ea
@ -16,6 +16,8 @@ SMTP_MAIL_FROM=noreply@example.com # Email отправителя
|
||||
|
||||
# === Безопасность ===
|
||||
API_PASS=your_secure_password_here # Пароль для доступа к API (используйте сложный!)
|
||||
JWT_SECRET=your_long_random_jwt_secret # Секрет подписи JWT для админ-панели
|
||||
ADMIN_SESSION_HOURS=12 # Срок действия JWT-сессии админа в часах
|
||||
PORT=3000 # Порт, на котором будет работать сервер
|
||||
HOST=0.0.0.0 # Хост для облачного деплоя
|
||||
FNS_TIMEOUT_MS=30000 # Таймаут создания чека в ФНС
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -47,4 +47,6 @@ build/
|
||||
!README.md
|
||||
!README.MD
|
||||
!server.js
|
||||
!public/
|
||||
!public/**
|
||||
!LICENSE
|
||||
|
||||
30
README.MD
30
README.MD
@ -12,6 +12,26 @@ npm start
|
||||
|
||||
Сервис запустится на порту из `PORT` или на `4000` по умолчанию.
|
||||
|
||||
Админ-интерфейс доступен по адресу:
|
||||
|
||||
```http
|
||||
GET /admin
|
||||
```
|
||||
|
||||
Для входа используется значение `API_PASS`. Через интерфейс можно смотреть локальный журнал чеков, синхронизировать последние чеки из кабинета ФНС, создавать чек вручную, проверять ФНС/SMTP и менять параметры сервиса без отдельного фронтенд-фреймворка.
|
||||
|
||||
Swagger UI доступен по адресу:
|
||||
|
||||
```http
|
||||
GET /swagger
|
||||
```
|
||||
|
||||
OpenAPI JSON доступен по адресу:
|
||||
|
||||
```http
|
||||
GET /openapi.json
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Сборка образа:
|
||||
@ -42,13 +62,19 @@ SMTP_USER=noreply@example.com
|
||||
SMTP_PASS=email_app_password
|
||||
SMTP_MAIL_FROM=noreply@example.com
|
||||
API_PASS=strong_api_password
|
||||
JWT_SECRET=long_random_jwt_secret
|
||||
ADMIN_SESSION_HOURS=12
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
FNS_TIMEOUT_MS=30000
|
||||
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
|
||||
|
||||
@ -94,3 +120,5 @@ Content-Type: application/json
|
||||
Поле `email` обязательно: на этот адрес сервис отправит письмо со ссылкой на чек после успешного создания чека в ФНС.
|
||||
|
||||
Если чек создан в ФНС, но письмо клиенту не отправилось, 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 fs from "fs/promises";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
dotenv.config();
|
||||
@ -17,42 +18,119 @@ const __dirname = path.dirname(__filename);
|
||||
const app = express();
|
||||
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 FNS_TIMEOUT_MS = Number(process.env.FNS_TIMEOUT_MS || 30000);
|
||||
const SMTP_TIMEOUT_MS = Number(process.env.SMTP_TIMEOUT_MS || 15000);
|
||||
const DATA_DIR = path.join(__dirname, "data");
|
||||
const ERROR_FILE = path.join(__dirname, "error.json");
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL;
|
||||
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
|
||||
const SMTP_SECURE = process.env.SMTP_SECURE
|
||||
? process.env.SMTP_SECURE === "true"
|
||||
: SMTP_PORT === 465;
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_SECURE,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS
|
||||
},
|
||||
connectionTimeout: SMTP_TIMEOUT_MS,
|
||||
greetingTimeout: SMTP_TIMEOUT_MS,
|
||||
socketTimeout: SMTP_TIMEOUT_MS
|
||||
});
|
||||
const RECEIPTS_FILE = path.join(DATA_DIR, "receipts.json");
|
||||
const CONFIG_FILE = path.join(DATA_DIR, "config.json");
|
||||
const PUBLIC_DIR = path.join(__dirname, "public");
|
||||
const CONFIG_KEYS = [
|
||||
"INN",
|
||||
"PASSWORD",
|
||||
"APPNAME",
|
||||
"ADMIN_EMAIL",
|
||||
"SMTP_HOST",
|
||||
"SMTP_PORT",
|
||||
"SMTP_SECURE",
|
||||
"SMTP_USER",
|
||||
"SMTP_PASS",
|
||||
"SMTP_MAIL_FROM",
|
||||
"API_PASS",
|
||||
"JWT_SECRET",
|
||||
"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 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() {
|
||||
if (!process.env.INN || !process.env.PASSWORD) {
|
||||
if (!getConfig("INN") || !getConfig("PASSWORD")) {
|
||||
throw new Error("INN and PASSWORD environment variables are required");
|
||||
}
|
||||
|
||||
if (!nalogApi) {
|
||||
nalogApi = new NalogApi({
|
||||
inn: process.env.INN,
|
||||
password: process.env.PASSWORD
|
||||
inn: getConfig("INN"),
|
||||
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() {
|
||||
return {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_SECURE,
|
||||
user: process.env.SMTP_USER,
|
||||
from: process.env.SMTP_MAIL_FROM
|
||||
host: getConfig("SMTP_HOST"),
|
||||
port: getSmtpPort(),
|
||||
secure: getSmtpSecure(),
|
||||
user: getConfig("SMTP_USER"),
|
||||
from: getConfig("SMTP_MAIL_FROM")
|
||||
};
|
||||
}
|
||||
|
||||
@ -123,7 +301,7 @@ async function createReceiptWithRetry(income, retries = MAX_RETRIES) {
|
||||
try {
|
||||
return await withTimeout(
|
||||
getNalogApi().addIncome(income),
|
||||
FNS_TIMEOUT_MS,
|
||||
getFnsTimeoutMs(),
|
||||
"FNS receipt creation"
|
||||
);
|
||||
} 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) {
|
||||
try {
|
||||
const html = `
|
||||
@ -185,21 +492,31 @@ async function notifyAdmin(errorData) {
|
||||
|
||||
await withTimeout(
|
||||
transporter.sendMail({
|
||||
from: process.env.SMTP_MAIL_FROM,
|
||||
to: ADMIN_EMAIL,
|
||||
subject: `Ошибка создания чека ${process.env.APPNAME}`,
|
||||
from: getConfig("SMTP_MAIL_FROM"),
|
||||
to: getConfig("ADMIN_EMAIL"),
|
||||
subject: `Ошибка создания чека ${getConfig("APPNAME")}`,
|
||||
html
|
||||
}),
|
||||
SMTP_TIMEOUT_MS,
|
||||
getSmtpTimeoutMs(),
|
||||
"Admin email sending"
|
||||
);
|
||||
|
||||
console.log(`Администратор ${ADMIN_EMAIL} уведомлен об ошибке`);
|
||||
console.log(`Администратор ${getConfig("ADMIN_EMAIL")} уведомлен об ошибке`);
|
||||
} catch (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) => {
|
||||
res.json({
|
||||
service: "fns-receipt-service",
|
||||
@ -249,7 +566,7 @@ app.get("/health/smtp", async (req, res) => {
|
||||
const smtp = smtpConfigForResponse();
|
||||
|
||||
try {
|
||||
await checkTcpConnection(process.env.SMTP_HOST, SMTP_PORT, SMTP_TIMEOUT_MS);
|
||||
await checkTcpConnection(getConfig("SMTP_HOST"), getSmtpPort(), getSmtpTimeoutMs());
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
status: "error",
|
||||
@ -261,7 +578,7 @@ app.get("/health/smtp", async (req, res) => {
|
||||
}
|
||||
|
||||
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 });
|
||||
} catch (err) {
|
||||
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) => {
|
||||
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" });
|
||||
}
|
||||
|
||||
@ -289,14 +696,14 @@ app.post("/api/v1/create-receipt", async (req, res) => {
|
||||
const total = calculateTotal(items);
|
||||
|
||||
const income = {
|
||||
name: `${process.env.APPNAME}`,
|
||||
name: `${getConfig("APPNAME")}`,
|
||||
amount: Number(total.toFixed(2)),
|
||||
quantity: 1
|
||||
};
|
||||
|
||||
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 price = Number(i.price) || 0;
|
||||
@ -346,7 +753,7 @@ style="display:inline-block;background:#ffdd2d;padding:16px 36px;border-radius:4
|
||||
</a>
|
||||
|
||||
<p style="font-size:12px;color:#999;margin-top:24px;">
|
||||
${process.env.APPNAME}
|
||||
${getConfig("APPNAME")}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
@ -361,12 +768,12 @@ ${process.env.APPNAME}
|
||||
try {
|
||||
await withTimeout(
|
||||
transporter.sendMail({
|
||||
from: process.env.SMTP_MAIL_FROM,
|
||||
from: getConfig("SMTP_MAIL_FROM"),
|
||||
to: email,
|
||||
subject: `Чек ${process.env.APPNAME}`,
|
||||
subject: `Чек ${getConfig("APPNAME")}`,
|
||||
html
|
||||
}),
|
||||
SMTP_TIMEOUT_MS,
|
||||
getSmtpTimeoutMs(),
|
||||
"Client email sending"
|
||||
);
|
||||
} catch (emailErr) {
|
||||
@ -383,6 +790,16 @@ ${process.env.APPNAME}
|
||||
error: emailErr.message || "Не удалось отправить email клиенту",
|
||||
technicalError
|
||||
});
|
||||
await saveReceipt({
|
||||
receiptId,
|
||||
email,
|
||||
items,
|
||||
amount: total,
|
||||
printLink,
|
||||
status: "created",
|
||||
emailSent: false,
|
||||
emailError: technicalError
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@ -395,6 +812,16 @@ ${process.env.APPNAME}
|
||||
});
|
||||
}
|
||||
|
||||
await saveReceipt({
|
||||
receiptId,
|
||||
email,
|
||||
items,
|
||||
amount: total,
|
||||
printLink,
|
||||
status: "created",
|
||||
emailSent: true
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: 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, () => {
|
||||
console.log(`✅ Сервер запущен: http://${HOST}:${PORT}`);
|
||||
console.log(`📁 Файл ошибок: ${ERROR_FILE}`);
|
||||
console.log(`🧾 Журнал чеков: ${RECEIPTS_FILE}`);
|
||||
console.log(`⚙️ UI настройки: ${CONFIG_FILE}`);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user