345 lines
8.9 KiB
JavaScript
345 lines
8.9 KiB
JavaScript
import express from "express";
|
||
import nodemailer from "nodemailer";
|
||
import dotenv from "dotenv";
|
||
import pkg from "lknpd-nalog-api";
|
||
import fs from "fs/promises";
|
||
import path from "path";
|
||
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 PORT = process.env.PORT || 4000;
|
||
const HOST = process.env.HOST || "0.0.0.0";
|
||
const MAX_RETRIES = 3;
|
||
const FNS_TIMEOUT_MS = Number(process.env.FNS_TIMEOUT_MS || 30000);
|
||
const SMTP_TIMEOUT_MS = Number(process.env.SMTP_TIMEOUT_MS || 15000);
|
||
const ERROR_FILE = path.join(__dirname, "error.json");
|
||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL;
|
||
|
||
const transporter = nodemailer.createTransport({
|
||
host: process.env.SMTP_HOST,
|
||
port: Number(process.env.SMTP_PORT),
|
||
secure: false,
|
||
auth: {
|
||
user: process.env.SMTP_USER,
|
||
pass: process.env.SMTP_PASS
|
||
},
|
||
connectionTimeout: SMTP_TIMEOUT_MS,
|
||
greetingTimeout: SMTP_TIMEOUT_MS,
|
||
socketTimeout: SMTP_TIMEOUT_MS
|
||
});
|
||
|
||
let nalogApi;
|
||
|
||
function getNalogApi() {
|
||
if (!process.env.INN || !process.env.PASSWORD) {
|
||
throw new Error("INN and PASSWORD environment variables are required");
|
||
}
|
||
|
||
if (!nalogApi) {
|
||
nalogApi = new NalogApi({
|
||
inn: process.env.INN,
|
||
password: process.env.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);
|
||
});
|
||
}
|
||
|
||
async function createReceiptWithRetry(income, retries = MAX_RETRIES) {
|
||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||
try {
|
||
return await withTimeout(
|
||
getNalogApi().addIncome(income),
|
||
FNS_TIMEOUT_MS,
|
||
"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 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: process.env.SMTP_MAIL_FROM,
|
||
to: ADMIN_EMAIL,
|
||
subject: `Ошибка создания чека ${process.env.APPNAME}`,
|
||
html
|
||
}),
|
||
SMTP_TIMEOUT_MS,
|
||
"Admin email sending"
|
||
);
|
||
|
||
console.log(`Администратор ${ADMIN_EMAIL} уведомлен об ошибке`);
|
||
} catch (err) {
|
||
console.error("Не удалось отправить уведомление администратору:", err);
|
||
}
|
||
}
|
||
|
||
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) => {
|
||
try {
|
||
await withTimeout(transporter.verify(), SMTP_TIMEOUT_MS, "SMTP health check");
|
||
res.json({ status: "ok", smtp: "ok" });
|
||
} catch (err) {
|
||
res.status(500).json({
|
||
status: "error",
|
||
smtp: "error",
|
||
message: err.message || "SMTP check failed"
|
||
});
|
||
}
|
||
});
|
||
|
||
app.post("/api/v1/create-receipt", async (req, res) => {
|
||
try {
|
||
const { api_pass, email, items } = req.body;
|
||
|
||
if (api_pass !== process.env.API_PASS) {
|
||
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: `${process.env.APPNAME}`,
|
||
amount: Number(total.toFixed(2)),
|
||
quantity: 1
|
||
};
|
||
|
||
const receiptId = await createReceiptWithRetry(income);
|
||
|
||
const printLink = `https://lknpd.nalog.ru/api/v1/receipt/${process.env.INN}/${receiptId}/print`;
|
||
|
||
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;">
|
||
${process.env.APPNAME}
|
||
</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</body>
|
||
</html>
|
||
`;
|
||
|
||
await withTimeout(
|
||
transporter.sendMail({
|
||
from: process.env.SMTP_MAIL_FROM,
|
||
to: email,
|
||
subject: `Чек ${process.env.APPNAME}`,
|
||
html
|
||
}),
|
||
SMTP_TIMEOUT_MS,
|
||
"Client email sending"
|
||
);
|
||
|
||
res.json({
|
||
success: 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 || "Неизвестная ошибка",
|
||
api_pass: req.body?.api_pass
|
||
};
|
||
|
||
await saveToErrorFile(errorData);
|
||
await notifyAdmin(errorData);
|
||
|
||
res.status(500).json({
|
||
error: "Не удалось создать чек. Данные сохранены для повторной попытки.",
|
||
saved_to_error_file: true
|
||
});
|
||
}
|
||
});
|
||
|
||
app.listen(PORT, HOST, () => {
|
||
console.log(`✅ Сервер запущен: http://${HOST}:${PORT}`);
|
||
console.log(`📁 Файл ошибок: ${ERROR_FILE}`);
|
||
});
|