fix #5

Merged
romtuck merged 1 commits from dd into main 2026-06-08 12:59:42 +03:00
9 changed files with 1993 additions and 47 deletions

View File

@ -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
View File

@ -47,4 +47,6 @@ build/
!README.md
!README.MD
!server.js
!public/
!public/**
!LICENSE

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View File

@ -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}`);
});