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 { createClient } from "redis"; 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", "REDIS_URL", "REDIS_HOST", "REDIS_PORT", "REDIS_USER", "REDIS_PASS", "REDIS_DB", "REDIS_KEY_PREFIX", "REDIS_TIMEOUT_MS", "PORT", "HOST", "FNS_TIMEOUT_MS", "SMTP_TIMEOUT_MS" ]; const SECRET_KEYS = new Set(["PASSWORD", "SMTP_PASS", "API_PASS", "JWT_SECRET", "REDIS_URL", "REDIS_PASS"]); let nalogApi; let runtimeConfig = {}; let transporter; let redisClient; let redisConfigSignature; 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 getRedisTimeoutMs() { return getNumberConfig("REDIS_TIMEOUT_MS", 5000); } function getRedisKey(name) { return `${getConfig("REDIS_KEY_PREFIX", "fns-receipt-service")}:${name}`; } function normalizeClientType(value) { const normalized = String(value || "").toLowerCase(); if ( normalized === "legal" || normalized === "juridical" || normalized === "company" || normalized === "organization" || normalized.includes("legal") || normalized.includes("юр") || normalized.includes("from_legal") ) { return "legal"; } return "individual"; } function taxRateForClientType(clientType) { return normalizeClientType(clientType) === "legal" ? 0.06 : 0.04; } function receiptAmount(receipt) { return Number(receipt.grossAmount ?? receipt.amount ?? receipt.totalAmount ?? 0) || 0; } function receiptDateValue(receipt) { return receipt.createdAt || receipt.operationTime || receipt.requestTime || receipt.raw?.operationTime || receipt.raw?.requestTime || receipt.raw?.createdAt || ""; } function withReceiptFinancials(receipt) { const clientType = normalizeClientType( receipt.clientType || receipt.raw?.client?.incomeType || receipt.raw?.incomeInfo?.client?.incomeType ); const grossAmount = Number(receiptAmount(receipt).toFixed(2)); const taxRate = taxRateForClientType(clientType); const taxAmount = Number((grossAmount * taxRate).toFixed(2)); const netAmount = Number((grossAmount - taxAmount).toFixed(2)); return { ...receipt, clientType, grossAmount, taxRate, taxAmount, netAmount, amount: grossAmount }; } 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 hasRedisConfig() { return Boolean(getConfig("REDIS_URL") || getConfig("REDIS_HOST")); } function currentRedisConfigSignature() { return JSON.stringify({ url: getConfig("REDIS_URL"), host: getConfig("REDIS_HOST"), port: getConfig("REDIS_PORT"), user: getConfig("REDIS_USER"), pass: getConfig("REDIS_PASS"), db: getConfig("REDIS_DB") }); } async function closeRedisClient() { if (!redisClient) return; try { if (redisClient.isOpen) { await redisClient.quit(); } } catch (err) { try { await redisClient.disconnect(); } catch (disconnectErr) { } } finally { redisClient = undefined; redisConfigSignature = undefined; } } async function getRedisClient() { if (!hasRedisConfig()) return null; const signature = currentRedisConfigSignature(); if (redisClient && redisConfigSignature === signature && redisClient.isOpen) { return redisClient; } await closeRedisClient(); const options = getConfig("REDIS_URL") ? { url: getConfig("REDIS_URL") } : { username: getConfig("REDIS_USER", "default"), password: getConfig("REDIS_PASS"), database: getNumberConfig("REDIS_DB", 0), socket: { host: getConfig("REDIS_HOST"), port: getNumberConfig("REDIS_PORT", 6379), connectTimeout: getRedisTimeoutMs() } }; const client = createClient(options); client.on("error", err => { console.error("Redis error:", err.message || err); }); await withTimeout(client.connect(), getRedisTimeoutMs(), "Redis connect"); redisClient = client; redisConfigSignature = signature; return redisClient; } async function readReceipts() { const fallbackReceipts = async () => { const data = await readJsonFile(RECEIPTS_FILE, []); return Array.isArray(data) ? data : []; }; try { const client = await getRedisClient(); if (!client) return (await fallbackReceipts()).map(withReceiptFinancials); const value = await withTimeout( client.get(getRedisKey("receipts")), getRedisTimeoutMs(), "Redis receipts read" ); if (!value) { const localReceipts = await fallbackReceipts(); if (localReceipts.length > 0) { await writeReceipts(localReceipts); } return localReceipts.map(withReceiptFinancials); } const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.map(withReceiptFinancials) : []; } catch (err) { console.error("Не удалось прочитать чеки из Redis, используется локальный файл:", err.message || err); return (await fallbackReceipts()).map(withReceiptFinancials); } } async function writeReceipts(receipts) { const normalizedReceipts = Array.isArray(receipts) ? receipts.map(withReceiptFinancials) : []; try { const client = await getRedisClient(); if (client) { await withTimeout( client.set(getRedisKey("receipts"), JSON.stringify(normalizedReceipts)), getRedisTimeoutMs(), "Redis receipts write" ); return; } } catch (err) { console.error("Не удалось записать чеки в Redis, используется локальный файл:", err.message || err); } await writeJsonFile(RECEIPTS_FILE, normalizedReceipts); } 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 getApiPassword(req) { return req.get("x-api-key") || req.get("x-admin-password") || req.body?.api_pass || ""; } 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 nextReceipts = await readReceipts(); const existingIndex = nextReceipts.findIndex(item => item.receiptId === receiptData.receiptId); const normalizedReceipt = { ...receiptData, createdAt: receiptData.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString() }; const receiptWithFinancials = withReceiptFinancials(normalizedReceipt); if (existingIndex >= 0) { nextReceipts[existingIndex] = { ...nextReceipts[existingIndex], ...receiptWithFinancials }; } else { nextReceipts.unshift(receiptWithFinancials); } await writeReceipts(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(getApiPassword(req))) { return res.status(401).json({ error: "Unauthorized" }); } next(); } function hasReceiptAccess(req) { return verifyJwt(getBearerToken(req)) || verifyAdminPassword(getApiPassword(req)); } function requireApiAccess(req, res, next) { if (!getConfig("API_PASS")) { return res.status(500).json({ error: "API_PASS is not configured" }); } if (!hasReceiptAccess(req)) { return res.status(401).json({ error: "Unauthorized" }); } next(); } 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 filterReceipts(receipts, query = {}) { const status = String(query.status || "").trim().toLowerCase(); const clientType = String(query.clientType || "").trim().toLowerCase(); const search = String(query.search || "").trim().toLowerCase(); const dateFrom = parseDateBoundary(query.dateFrom || query.from, false); const dateTo = parseDateBoundary(query.dateTo || query.to, true); return receipts.filter(receipt => { if (status && String(receipt.status || "").toLowerCase() !== status) return false; if (clientType && normalizeClientType(receipt.clientType) !== normalizeClientType(clientType)) return false; if (dateFrom || dateTo) { const receiptTime = new Date(receiptDateValue(receipt)).getTime(); if (Number.isNaN(receiptTime)) return false; if (dateFrom && receiptTime < dateFrom.getTime()) return false; if (dateTo && receiptTime > dateTo.getTime()) return false; } if (!search) return true; const itemText = (receipt.items || []) .map(item => `${item.id || ""} ${item.name || item.title || ""}`) .join(" "); const text = `${receipt.email || ""} ${receipt.receiptId || ""} ${itemText}`.toLowerCase(); return text.includes(search); }); } function parseDateBoundary(value, endOfDay = false) { const text = String(value || "").trim(); if (!text) return null; const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text); const date = dateOnly ? new Date(Date.UTC( Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]), endOfDay ? 23 : 0, endOfDay ? 59 : 0, endOfDay ? 59 : 0, endOfDay ? 999 : 0 )) : new Date(text); if (Number.isNaN(date.getTime())) { throw new Error("Период должен быть в формате YYYY-MM-DD или ISO date-time"); } return date; } function paginate(items, query = {}) { const limit = Math.min(Math.max(Number(query.limit) || 50, 1), 200); const offset = Math.max(Number(query.offset) || 0, 0); return { items: items.slice(offset, offset + limit), limit, offset, total: items.length }; } function csvCell(value) { if (value === null || value === undefined) return ""; const text = Array.isArray(value) ? value.join("; ") : String(value); const protectedText = /^[=+\-@\t\r]/.test(text) ? `'${text}` : text; return `"${protectedText.replace(/"/g, '""')}"`; } function receiptsToCsv(receipts) { const columns = [ ["createdAt", "Дата"], ["receiptId", "ID чека"], ["email", "Email"], ["clientType", "Тип клиента"], ["grossAmount", "Грязными"], ["taxRate", "Ставка налога"], ["taxAmount", "Налог"], ["netAmount", "Чистыми"], ["status", "Статус"], ["emailSent", "Письмо отправлено"], ["source", "Источник"], ["items", "Позиции"], ["printLink", "Ссылка"] ]; const rows = receipts.map(receipt => columns.map(([key]) => { if (key === "createdAt") return csvCell(receiptDateValue(receipt)); if (key === "source") return csvCell(receipt.syncedFromFns ? "fns" : "local"); if (key === "items") { return csvCell((receipt.items || []) .map(item => [item.id, item.name || item.title, item.price, item.quantity].filter(Boolean).join(" ")) .filter(Boolean)); } return csvCell(receipt[key]); }).join(",")); return [ columns.map(([, label]) => csvCell(label)).join(","), ...rows ].join("\n"); } function exportFileName(query = {}) { const from = String(query.dateFrom || query.from || "all").replace(/[^\dA-Za-z_-]/g, "-"); const to = String(query.dateTo || query.to || "all").replace(/[^\dA-Za-z_-]/g, "-"); return `receipts-${from}-${to}.csv`; } function isCancelledFnsIncome(item) { const values = [ item.status, item.state, item.operationType, item.incomeType, item.cancellationInfo?.status, item.incomeInfo?.status ].filter(Boolean).map(value => String(value).toLowerCase()); return Boolean( item.cancelTime || item.cancelled || item.isCancelled || item.cancellationInfo || item.incomeInfo?.cancelTime || values.some(value => value.includes("cancel") || value.includes("аннул") || value.includes("сторн") || value.includes("void") ) ); } 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); const cancelled = isCancelledFnsIncome(item); const clientType = normalizeClientType(item.client?.incomeType || item.incomeInfo?.client?.incomeType || item.incomeType); return withReceiptFinancials({ source: "fns", receiptId, email: "", amount, grossAmount: amount, clientType, status: cancelled ? "cancelled" : "created", cancelled, emailSent: null, createdAt: item.operationTime || item.requestTime || item.createdAt || null, printLink: receiptId ? receiptPrintLink(receiptId) : "", items: item.services || [], raw: item }); } function monthRange(month) { if (!/^\d{4}-\d{2}$/.test(String(month || ""))) { throw new Error("Месяц должен быть в формате YYYY-MM"); } const [year, monthNumber] = month.split("-").map(Number); const from = new Date(Date.UTC(year, monthNumber - 1, 1, 0, 0, 0, 0)); const to = new Date(Date.UTC(year, monthNumber, 0, 23, 59, 59, 999)); return { from, to }; } async function getFnsReceipts({ offset = 0, limit = 50, from, to } = {}) { const params = new URLSearchParams({ offset: String(offset), limit: String(Math.min(Number(limit) || 50, 50)), sortBy: "operation_time:desc" }); if (from) params.set("from", from.toISOString()); if (to) params.set("to", to.toISOString()); 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 getFnsReceiptsForMonth(month) { const range = monthRange(month); const limit = 50; const maxPages = 100; const allItems = []; let total = 0; for (let page = 0; page < maxPages; page++) { const offset = page * limit; const result = await getFnsReceipts({ offset, limit, ...range }); total = Number(result.total) || total; allItems.push(...result.items); if (result.items.length < limit) break; if (total && allItems.length >= total) break; } return { items: allItems, total, month, from: range.from.toISOString(), to: range.to.toISOString() }; } async function syncFnsReceipts({ month } = {}) { const targetMonth = month || new Date().toISOString().slice(0, 7); const fnsReceipts = await getFnsReceiptsForMonth(targetMonth); const merged = await readReceipts(); for (const fnsReceipt of fnsReceipts.items) { if (!fnsReceipt.receiptId) continue; const existingIndex = merged.findIndex(item => item.receiptId === fnsReceipt.receiptId); if (existingIndex >= 0) { const existingReceipt = merged[existingIndex]; merged[existingIndex] = { ...existingReceipt, ...fnsReceipt, email: existingReceipt.email || fnsReceipt.email, clientType: existingReceipt.clientType || fnsReceipt.clientType, emailSent: existingReceipt.emailSent ?? fnsReceipt.emailSent, items: existingReceipt.items?.length ? existingReceipt.items : fnsReceipt.items, fns: fnsReceipt.raw, updatedAt: new Date().toISOString() }; } else { merged.push({ ...fnsReceipt, status: fnsReceipt.status || "created", syncedFromFns: true, updatedAt: new Date().toISOString() }); } } const receiptsWithFinancials = merged.map(withReceiptFinancials); receiptsWithFinancials.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)); await writeReceipts(receiptsWithFinancials); return { synced: fnsReceipts.items.length, activeSynced: fnsReceipts.items.filter(item => item.status !== "cancelled").length, cancelledSynced: fnsReceipts.items.filter(item => item.status === "cancelled").length, total: receiptsWithFinancials.length, month: targetMonth, from: fnsReceipts.from, to: fnsReceipts.to, storage: hasRedisConfig() ? "redis" : "file" }; } 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("/api/v1/auth/login", (req, res) => { if (!getConfig("API_PASS")) { return res.status(500).json({ error: "API_PASS is not configured" }); } if (!verifyAdminPassword(req.body?.password || req.body?.api_pass)) { 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 }, storage: { type: hasRedisConfig() ? "redis" : "file", receiptsKey: getRedisKey("receipts") } }); }); 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 closeRedisClient(); await writeJsonFile(CONFIG_FILE, runtimeConfig); res.json({ success: true, config: publicConfig() }); }); app.get("/admin/api/receipts", requireAdmin, async (req, res) => { const receipts = await readReceipts(); const errors = await readJsonFile(ERROR_FILE, []); res.json({ receipts: Array.isArray(receipts) ? receipts : [], errors: Array.isArray(errors) ? errors : [], storage: hasRedisConfig() ? "redis" : "file" }); }); app.get("/admin/api/receipts/export", requireAdmin, async (req, res) => { try { const receipts = filterReceipts(await readReceipts(), req.query); const csv = receiptsToCsv(receipts); res.setHeader("Content-Type", "text/csv; charset=utf-8"); res.setHeader("Content-Disposition", `attachment; filename="${exportFileName(req.query)}"`); res.send(`\uFEFF${csv}`); } catch (err) { res.status(400).json({ error: err.message || "Неверный период экспорта" }); } }); app.get("/api/v1/receipts", requireApiAccess, async (req, res) => { let receipts; try { receipts = filterReceipts(await readReceipts(), req.query); } catch (err) { return res.status(400).json({ error: err.message || "Неверный период фильтрации" }); } const page = paginate(receipts, req.query); res.json({ receipts: page.items, pagination: { limit: page.limit, offset: page.offset, total: page.total }, storage: hasRedisConfig() ? "redis" : "file" }); }); app.get("/api/v1/receipts/export", requireApiAccess, async (req, res) => { try { const receipts = filterReceipts(await readReceipts(), req.query); const csv = receiptsToCsv(receipts); res.setHeader("Content-Type", "text/csv; charset=utf-8"); res.setHeader("Content-Disposition", `attachment; filename="${exportFileName(req.query)}"`); res.send(`\uFEFF${csv}`); } catch (err) { res.status(400).json({ error: err.message || "Неверный период экспорта" }); } }); app.get("/api/v1/receipts/:receiptId", requireApiAccess, async (req, res) => { const receipts = await readReceipts(); const receipt = receipts.find(item => item.receiptId === req.params.receiptId); if (!receipt) { return res.status(404).json({ error: "Receipt not found" }); } res.json({ receipt }); }); app.post("/admin/api/receipts/sync", requireAdmin, async (req, res) => { try { const result = await syncFnsReceipts({ month: req.body?.month }); res.json({ success: true, ...result }); } catch (err) { res.status(500).json({ error: "Не удалось синхронизировать чеки из ФНС", technicalError: formatTechnicalError(err) }); } }); app.post("/api/v1/receipts/sync", requireApiAccess, async (req, res) => { try { const result = await syncFnsReceipts({ month: req.body?.month }); 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; const clientType = normalizeClientType(req.body?.clientType); 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 `
|