From 87f6f355721b09988874867a1de5849ce099161b Mon Sep 17 00:00:00 2001 From: romantarkin Date: Mon, 8 Jun 2026 14:58:11 +0500 Subject: [PATCH] fix --- .env.example | 2 + .gitignore | 2 + README.MD | 30 +- public/app.js | 304 ++++++++++++++++++++ public/index.html | 121 ++++++++ public/openapi.json | 670 ++++++++++++++++++++++++++++++++++++++++++++ public/styles.css | 355 +++++++++++++++++++++++ public/swagger.html | 30 ++ server.js | 526 +++++++++++++++++++++++++++++++--- 9 files changed, 1993 insertions(+), 47 deletions(-) create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/openapi.json create mode 100644 public/styles.css create mode 100644 public/swagger.html diff --git a/.env.example b/.env.example index 7a01ac1..53553d8 100644 --- a/.env.example +++ b/.env.example @@ -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 # Таймаут создания чека в ФНС diff --git a/.gitignore b/.gitignore index b78f3fb..08748e5 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,6 @@ build/ !README.md !README.MD !server.js +!public/ +!public/** !LICENSE diff --git a/README.MD b/README.MD index 09cc5c5..5baee0b 100644 --- a/README.MD +++ b/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 `. + +`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 пользователя может быть неизвестен. diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..d078267 --- /dev/null +++ b/public/app.js @@ -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 + ? 'отправлено' + : receipt.emailSent === false + ? 'ошибка' + : 'нет данных'; + const source = receipt.syncedFromFns ? "ФНС" : "локально"; + const link = receipt.printLink + ? `открыть` + : ""; + + return ` + + ${formatDate(receipt.createdAt || receipt.operationTime)} + ${receipt.email || 'неизвестно'} + +
${receipt.receiptId || 'нет ID'}
+
${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}
+ + ${formatMoney(amount)} ₽ + ${emailBadge} + ${source} + ${link} + + `; + }).join("") || 'Чеков пока нет'; +} + +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 ` +
+ + +
+ `; + }).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)); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..dad9a51 --- /dev/null +++ b/public/index.html @@ -0,0 +1,121 @@ + + + + + + FNS Receipt Service + + + +
+
+

FNS Receipt Service

+

Админ-панель чеков и настроек

+
+ + +
+ + + +
+
+
+ + + +
+ +
+
0чеков
+
0.00рублей
+
0ошибок
+
+ +
+ + + + + + + + + + + + + +
ДатаПользовательЧекСуммаПисьмоИсточник
+
+
+ +
+
+ +
+ Позиции + +
+
+ +
+
+ +
+
+
+
+

Параметры сервиса

+

Пустое секретное поле оставляет текущее значение без изменений.

+
+ +
+
+
+
+ +
+
+ + + +
+
Нет данных
+
+
+ + + + + + + + diff --git a/public/openapi.json b/public/openapi.json new file mode 100644 index 0000000..82b8473 --- /dev/null +++ b/public/openapi.json @@ -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": "Неверный пароль" + } + } + } + } + } +} diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..5ea7bb4 --- /dev/null +++ b/public/styles.css @@ -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; + } +} diff --git a/public/swagger.html b/public/swagger.html new file mode 100644 index 0000000..de6c963 --- /dev/null +++ b/public/swagger.html @@ -0,0 +1,30 @@ + + + + + + FNS Receipt Service API + + + + +
+ + + + diff --git a/server.js b/server.js index 4ce5f7b..d58d044 100644 --- a/server.js +++ b/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

-${process.env.APPNAME} +${getConfig("APPNAME")}

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