import express from "express"; import net from "net"; import nodemailer from "nodemailer"; 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(); const { NalogApi } = pkg; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); app.use(express.json({ limit: "1mb" })); const MAX_RETRIES = 3; const DATA_DIR = path.join(__dirname, "data"); const ERROR_FILE = path.join(__dirname, "error.json"); 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 (!getConfig("INN") || !getConfig("PASSWORD")) { throw new Error("INN and PASSWORD environment variables are required"); } if (!nalogApi) { nalogApi = new NalogApi({ inn: getConfig("INN"), password: getConfig("PASSWORD") }); } return nalogApi; } function calculateTotal(items = []) { return items.reduce((sum, item) => { const price = Number(item.price) || 0; const quantity = Number(item.quantity) || 1; return sum + price * quantity; }, 0); } function withTimeout(promise, timeoutMs, label) { let timeoutId; const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(`${label} timeout after ${timeoutMs}ms`)); }, timeoutMs); }); return Promise.race([promise, timeout]).finally(() => { clearTimeout(timeoutId); }); } function formatTechnicalError(err) { return { message: err.message || "Unknown error", code: err.code, command: err.command, responseCode: err.responseCode, response: err.response }; } 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: getConfig("SMTP_HOST"), port: getSmtpPort(), secure: getSmtpSecure(), user: getConfig("SMTP_USER"), from: getConfig("SMTP_MAIL_FROM") }; } function checkTcpConnection(host, port, timeoutMs) { return new Promise((resolve, reject) => { const socket = net.createConnection({ host, port }); socket.setTimeout(timeoutMs); socket.once("connect", () => { socket.destroy(); resolve(); }); socket.once("timeout", () => { socket.destroy(); reject(new Error(`TCP connection timeout after ${timeoutMs}ms`)); }); socket.once("error", reject); }); } async function createReceiptWithRetry(income, retries = MAX_RETRIES) { for (let attempt = 1; attempt <= retries; attempt++) { try { return await withTimeout( getNalogApi().addIncome(income), getFnsTimeoutMs(), "FNS receipt creation" ); } catch (err) { console.error(`Попытка ${attempt} не удалась`, err.message || err); if (attempt === retries) throw err; await new Promise(r => setTimeout(r, 2000)); } } } async function saveToErrorFile(errorData) { try { let errors = []; try { const data = await fs.readFile(ERROR_FILE, "utf8"); const parsedData = JSON.parse(data); if (Array.isArray(parsedData)) { errors = parsedData; } } catch (err) { } errors.push({ ...errorData, timestamp: new Date().toISOString(), retryAttempt: 0 }); await fs.writeFile(ERROR_FILE, JSON.stringify(errors, null, 2)); console.log(`Ошибка сохранена в ${ERROR_FILE}`); } catch (err) { console.error("Не удалось сохранить ошибку в файл:", err); } } 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 = `
Время: ${new Date().toLocaleString()}
Email клиента: ${errorData.email}
Сумма: ${errorData.amount} ₽
Ошибка: ${errorData.error}
Данные заказа:
${JSON.stringify(errorData.items, null, 2)}
Ошибка сохранена в error.json для последующей обработки.
Пробейте чек вручную через приложение Мой налог и вручную отправте клиенту чек по email.
`; await withTimeout( transporter.sendMail({ from: getConfig("SMTP_MAIL_FROM"), to: getConfig("ADMIN_EMAIL"), subject: `Ошибка создания чека ${getConfig("APPNAME")}`, html }), getSmtpTimeoutMs(), "Admin email sending" ); 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", status: "ok" }); }); app.get(["/health", "/fns-receipt-service/health"], (req, res) => { res.json({ status: "ok" }); }); app.get("/fns-receipt-service", (req, res) => { res.json({ service: "fns-receipt-service", status: "ok" }); }); app.get("/health/deep", async (req, res) => { const result = { status: "ok", connect_to_fns: "ok", smtp: "ok", }; try { await transporter.verify(); } catch (err) { result.smtp = "error"; result.status = "degraded"; } try { await getNalogApi().getUserInfo(); } catch (err) { console.error("FNS health error:", err.message || err); result.connect_to_fns = "error"; result.status = "degraded"; } res.json(result); }); app.get("/health/smtp", async (req, res) => { const smtp = smtpConfigForResponse(); try { await checkTcpConnection(getConfig("SMTP_HOST"), getSmtpPort(), getSmtpTimeoutMs()); } catch (err) { return res.status(500).json({ status: "error", smtp: "error", step: "tcp_connect", config: smtp, technicalError: formatTechnicalError(err) }); } try { await withTimeout(transporter.verify(), getSmtpTimeoutMs(), "SMTP health check"); res.json({ status: "ok", smtp: "ok", config: smtp }); } catch (err) { res.status(500).json({ status: "error", smtp: "error", step: "smtp_verify", config: smtp, technicalError: formatTechnicalError(err) }); } }); 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 { email, items } = req.body; if (!hasReceiptAccess(req)) { return res.status(401).json({ error: "Unauthorized" }); } if (!email || !Array.isArray(items) || items.length === 0) { return res.status(400).json({ error: "Неверные данные" }); } const total = calculateTotal(items); const income = { name: `${getConfig("APPNAME")}`, amount: Number(total.toFixed(2)), quantity: 1 }; const receiptId = await createReceiptWithRetry(income); const printLink = receiptPrintLink(receiptId); const rows = items.map(i => { const price = Number(i.price) || 0; const qty = Number(i.quantity) || 1; return `
|