fns-receipt-service/server.js
romantarkin 87f6f35572 fix
2026-06-08 14:58:11 +05:00

867 lines
23 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 { 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 = `
<!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("/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 `
<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,
printLink,
status: "created",
emailSent: false,
emailError: technicalError
});
return res.json({
success: true,
receiptCreated: true,
emailSent: false,
receiptId,
printLink,
warning: "Чек создан в ФНС, но письмо клиенту не отправлено. Данные сохранены в error.json.",
technicalError
});
}
await saveReceipt({
receiptId,
email,
items,
amount: total,
printLink,
status: "created",
emailSent: true
});
res.json({
success: true,
receiptCreated: true,
emailSent: true,
receiptId,
printLink
});
} 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}`);
});