1091 lines
29 KiB
JavaScript
1091 lines
29 KiB
JavaScript
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 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();
|
||
|
||
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;
|
||
}
|
||
|
||
const parsed = JSON.parse(value);
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch (err) {
|
||
console.error("Не удалось прочитать чеки из Redis, используется локальный файл:", err.message || err);
|
||
return await fallbackReceipts();
|
||
}
|
||
}
|
||
|
||
async function writeReceipts(receipts) {
|
||
const normalizedReceipts = Array.isArray(receipts) ? receipts : [];
|
||
|
||
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 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()
|
||
};
|
||
|
||
if (existingIndex >= 0) {
|
||
nextReceipts[existingIndex] = {
|
||
...nextReceipts[existingIndex],
|
||
...normalizedReceipt
|
||
};
|
||
} else {
|
||
nextReceipts.unshift(normalizedReceipt);
|
||
}
|
||
|
||
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(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 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);
|
||
|
||
return {
|
||
source: "fns",
|
||
receiptId,
|
||
email: "",
|
||
amount,
|
||
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,
|
||
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()
|
||
});
|
||
}
|
||
}
|
||
|
||
merged.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
|
||
await writeReceipts(merged);
|
||
|
||
return {
|
||
synced: fnsReceipts.items.length,
|
||
activeSynced: fnsReceipts.items.filter(item => item.status !== "cancelled").length,
|
||
cancelledSynced: fnsReceipts.items.filter(item => item.status === "cancelled").length,
|
||
total: merged.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("/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.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.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}`);
|
||
});
|