fns-receipt-service/server.js
romantarkin 3a717d5c06 fix
2026-06-09 12:30:59 +05:00

1248 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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();
return receipts.filter(receipt => {
if (status && String(receipt.status || "").toLowerCase() !== status) return false;
if (clientType && normalizeClientType(receipt.clientType) !== normalizeClientType(clientType)) 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 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 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 = `
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>Ошибка создания чека</title>
</head>
<body>
<h2>⚠️ Ошибка при создании чека</h2>
<p><b>Время:</b> ${new Date().toLocaleString()}</p>
<p><b>Email клиента:</b> ${errorData.email}</p>
<p><b>Сумма:</b> ${errorData.amount} ₽</p>
<p><b>Ошибка:</b> ${errorData.error}</p>
<p><b>Данные заказа:</b></p>
<pre>${JSON.stringify(errorData.items, null, 2)}</pre>
<p>Ошибка сохранена в error.json для последующей обработки.</p>
<p>Пробейте чек вручную через приложение Мой налог и вручную отправте клиенту чек по email.</p>
</body>
</html>
`;
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("/api/v1/receipts", requireApiAccess, async (req, res) => {
const receipts = filterReceipts(await readReceipts(), req.query);
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/: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 `
<tr>
<td>${i.id}</td>
<td>${i.name}</td>
<td>${price.toFixed(2)}</td>
<td>${qty}</td>
<td>${(price * qty).toFixed(2)}</td>
</tr>
`;
}).join("");
const html = `
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>Чек</title>
<link rel="stylesheet" href="https://cdn.email.ga1maz.ru/emails/styles.css">
</head>
<body style="margin:0;padding:0;font-family:Arial,sans-serif;color:#333;background:#f5f6f7;">
<table width="100%" bgcolor="#f5f6f7">
<tr>
<td align="center">
<table width="600" bgcolor="#ffffff" style="margin:40px auto;">
<tr>
<td style="padding:24px;text-align:center;color:#333;">
<img src="https://cdn.email.ga1maz.ru/emails/main.png" width="536" />
<h2 style="color:#333;">Ваш чек</h2>
<p style="color:#333;">Чек сформирован в ФНС (Мой налог)</p>
<table width="100%" border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;color:#333;">
<tr style="background:#eee;">
<th>ID</th><th>Название</th><th>Цена</th><th>Кол-во</th><th>Сумма</th>
</tr>
${rows}
</table>
<p><b>Итого:</b> ${total.toFixed(2)} ₽</p>
<a href="${printLink}" target="_blank"
style="display:inline-block;background:#ffdd2d;padding:16px 36px;border-radius:4px;color:#333;text-decoration:none;">
Посмотреть чек
</a>
<p style="font-size:12px;color:#999;margin-top:24px;">
${getConfig("APPNAME")}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
try {
await withTimeout(
transporter.sendMail({
from: getConfig("SMTP_MAIL_FROM"),
to: email,
subject: `Чек ${getConfig("APPNAME")}`,
html
}),
getSmtpTimeoutMs(),
"Client email sending"
);
} catch (emailErr) {
console.error("Чек создан, но email клиенту не отправлен:", emailErr);
const technicalError = formatTechnicalError(emailErr);
await saveToErrorFile({
type: "email_send_failed",
email,
items,
amount: total,
receiptId,
printLink,
error: emailErr.message || "Не удалось отправить email клиенту",
technicalError
});
await saveReceipt({
receiptId,
email,
items,
amount: total,
clientType,
printLink,
status: "created",
emailSent: false,
emailError: technicalError
});
return res.json({
success: true,
receiptCreated: true,
emailSent: false,
receiptId,
printLink,
warning: "Чек создан в ФНС, но письмо клиенту не отправлено. Данные сохранены в error.json.",
technicalError,
...withReceiptFinancials({ amount: total, clientType })
});
}
await saveReceipt({
receiptId,
email,
items,
amount: total,
clientType,
printLink,
status: "created",
emailSent: true
});
res.json({
success: true,
receiptCreated: true,
emailSent: true,
receiptId,
printLink,
...withReceiptFinancials({ amount: total, clientType })
});
} catch (err) {
console.error("Ошибка создания чека:", err);
const errorData = {
email: req.body?.email,
items: req.body?.items,
amount: calculateTotal(req.body?.items),
error: err.message || "Неизвестная ошибка",
technicalError: formatTechnicalError(err),
api_pass: req.body?.api_pass
};
await saveToErrorFile(errorData);
await notifyAdmin(errorData);
res.status(500).json({
error: "Не удалось создать чек. Данные сохранены для повторной попытки.",
saved_to_error_file: true,
technicalError: formatTechnicalError(err)
});
}
});
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}`);
});