diff --git a/.env.example b/.env.example
index 53553d8..b8f6d5b 100644
--- a/.env.example
+++ b/.env.example
@@ -18,6 +18,18 @@ SMTP_MAIL_FROM=noreply@example.com # Email отправителя
API_PASS=your_secure_password_here # Пароль для доступа к API (используйте сложный!)
JWT_SECRET=your_long_random_jwt_secret # Секрет подписи JWT для админ-панели
ADMIN_SESSION_HOURS=12 # Срок действия JWT-сессии админа в часах
+
+# === Redis хранилище чеков ===
+# Можно указать REDIS_URL или отдельные параметры ниже.
+REDIS_URL= # Например redis://default:password@host:6379/0
+REDIS_HOST=127.0.0.1 # Redis host
+REDIS_PORT=6379 # Redis port
+REDIS_USER=default # Redis user
+REDIS_PASS=redis_password # Redis password
+REDIS_DB=0 # Redis database number
+REDIS_KEY_PREFIX=fns-receipt-service # Prefix for Redis keys
+REDIS_TIMEOUT_MS=5000 # Redis operation timeout
+
PORT=3000 # Порт, на котором будет работать сервер
HOST=0.0.0.0 # Хост для облачного деплоя
FNS_TIMEOUT_MS=30000 # Таймаут создания чека в ФНС
diff --git a/README.MD b/README.MD
index 5baee0b..d7eb8e8 100644
--- a/README.MD
+++ b/README.MD
@@ -64,6 +64,13 @@ SMTP_MAIL_FROM=noreply@example.com
API_PASS=strong_api_password
JWT_SECRET=long_random_jwt_secret
ADMIN_SESSION_HOURS=12
+REDIS_HOST=127.0.0.1
+REDIS_PORT=6379
+REDIS_USER=default
+REDIS_PASS=redis_password
+REDIS_DB=0
+REDIS_KEY_PREFIX=fns-receipt-service
+REDIS_TIMEOUT_MS=5000
PORT=3000
HOST=0.0.0.0
FNS_TIMEOUT_MS=30000
@@ -76,6 +83,14 @@ SMTP_TIMEOUT_MS=15000
Настройки, измененные через UI, сохраняются в `data/config.json` и перекрывают значения из `.env`. Сетевые параметры `PORT` и `HOST` применятся после перезапуска процесса.
+Чеки сохраняются в Redis, если задан `REDIS_URL` или `REDIS_HOST`. Основной ключ:
+
+```text
+fns-receipt-service:receipts
+```
+
+Префикс можно изменить через `REDIS_KEY_PREFIX`. Если Redis не настроен или временно недоступен, сервис использует локальный fallback `data/receipts.json`.
+
## Timeweb Cloud
Что заполнить при Docker-деплое:
@@ -121,4 +136,4 @@ Content-Type: application/json
Если чек создан в ФНС, но письмо клиенту не отправилось, API вернет `success: true`, `receiptCreated: true`, `emailSent: false`, `receiptId`, `printLink` и `technicalError`. Ошибка отправки сохранится в `error.json`.
-Все успешно созданные чеки сохраняются в локальный журнал `data/receipts.json`, чтобы в UI была связь между чеком, email пользователя и позициями заказа. Синхронизация с ФНС подтягивает последние чеки из кабинета, но для старых чеков, созданных не этим сервисом, email пользователя может быть неизвестен.
+Все успешно созданные чеки сохраняются в Redis, чтобы в UI была связь между чеком, email пользователя и позициями заказа. Синхронизация с ФНС выполняется за выбранный месяц, подтягивает страницы по 50 записей до конца месяца и помечает аннулированные чеки как `cancelled`. Аннулированные чеки показываются в списке, но не учитываются в количестве действующих чеков и итоговой сумме.
diff --git a/package-lock.json b/package-lock.json
index ed07d80..f1a4a65 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,12 +12,85 @@
"dotenv": "^17.2.3",
"express": "^5.2.1",
"lknpd-nalog-api": "^1.0.1",
- "nodemailer": "^7.0.13"
+ "nodemailer": "^7.0.13",
+ "redis": "^6.0.0"
},
"engines": {
"node": ">=22"
}
},
+ "node_modules/@redis/bloom": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.0.0.tgz",
+ "integrity": "sha512-P0n5NkV9IIdT6nYXOfMHG83sho8pE7Nay7yw27wOGVLv4DthgvzebpGz6m7VuMTizeJmw3LPw2Xek5wFUhGpVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.0.0"
+ },
+ "peerDependencies": {
+ "@redis/client": "^6.0.0"
+ }
+ },
+ "node_modules/@redis/client": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.0.0.tgz",
+ "integrity": "sha512-NS4iIT25r24sAjNQ2nSRdCW5jPJoV0rxkBee27oTeR+RXaOu89cjIsrww5rPBaYVGVdL1QCx9uz9141gZiSKdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cluster-key-slot": "1.1.2"
+ },
+ "engines": {
+ "node": ">= 20.0.0"
+ },
+ "peerDependencies": {
+ "@node-rs/xxhash": "^1.1.0",
+ "@opentelemetry/api": ">=1 <2"
+ },
+ "peerDependenciesMeta": {
+ "@node-rs/xxhash": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@redis/json": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@redis/json/-/json-6.0.0.tgz",
+ "integrity": "sha512-F+eqFfgPcy57Zs1KW7UtLnBtRk6lxAUIoe7dyZerpm6e+ssYXG/dWJrbrHFYs0b7tt6QBtYpVuukBuM9XqhUAg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.0.0"
+ },
+ "peerDependencies": {
+ "@redis/client": "^6.0.0"
+ }
+ },
+ "node_modules/@redis/search": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@redis/search/-/search-6.0.0.tgz",
+ "integrity": "sha512-VHuCJ2W0YWFixGZh/l//8JiyOsD4gN+NhjdRAGIoUe0UQ4mtq1NyY2ZJ973XT+vYhaU21XdK8r8oNrd5n7wbzQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.0.0"
+ },
+ "peerDependencies": {
+ "@redis/client": "^6.0.0"
+ }
+ },
+ "node_modules/@redis/time-series": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.0.0.tgz",
+ "integrity": "sha512-QWhkYsg+3lhBrBf+cbzybtV8LQcSrk7iXIgTaGU+pHNFTkql7TpVRE24ROS6M2ybVIV6O/zxTqfxgxxYiqyw0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.0.0"
+ },
+ "peerDependencies": {
+ "@redis/client": "^6.0.0"
+ }
+ },
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -93,6 +166,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -687,6 +769,22 @@
"node": ">= 0.10"
}
},
+ "node_modules/redis": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-6.0.0.tgz",
+ "integrity": "sha512-n9Thfc39OXleEoPT2k5gwKsqY+HfCww3YS71ofcr9KKbkn89bpjU9dToIlD+JRdM3/GYQkwMtVgTxLyed+LptQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@redis/bloom": "6.0.0",
+ "@redis/client": "6.0.0",
+ "@redis/json": "6.0.0",
+ "@redis/search": "6.0.0",
+ "@redis/time-series": "6.0.0"
+ },
+ "engines": {
+ "node": ">= 20.0.0"
+ }
+ },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
diff --git a/package.json b/package.json
index f5a753f..237fe71 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"dotenv": "^17.2.3",
"express": "^5.2.1",
"lknpd-nalog-api": "^1.0.1",
- "nodemailer": "^7.0.13"
+ "nodemailer": "^7.0.13",
+ "redis": "^6.0.0"
}
}
diff --git a/public/app.js b/public/app.js
index d078267..44694b8 100644
--- a/public/app.js
+++ b/public/app.js
@@ -95,9 +95,10 @@ function receiptText(receipt) {
function renderReceipts() {
const query = $("#receipt-search").value.trim().toLowerCase();
const filtered = state.receipts.filter(receipt => receiptText(receipt).includes(query));
- const total = filtered.reduce((sum, receipt) => sum + (Number(receipt.amount) || Number(receipt.totalAmount) || 0), 0);
+ const activeReceipts = filtered.filter(receipt => receipt.status !== "cancelled");
+ const total = activeReceipts.reduce((sum, receipt) => sum + (Number(receipt.amount) || Number(receipt.totalAmount) || 0), 0);
- $("#metric-count").textContent = filtered.length;
+ $("#metric-count").textContent = activeReceipts.length;
$("#metric-total").textContent = formatMoney(total);
$("#metric-errors").textContent = state.errors.length;
@@ -109,6 +110,9 @@ function renderReceipts() {
? 'ошибка'
: 'нет данных';
const source = receipt.syncedFromFns ? "ФНС" : "локально";
+ const statusBadge = receipt.status === "cancelled"
+ ? 'аннулирован'
+ : 'действует';
const link = receipt.printLink
? `открыть`
: "";
@@ -122,12 +126,13 @@ function renderReceipts() {
${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}
${formatMoney(amount)} ₽ |
+ ${statusBadge} |
${emailBadge} |
${source} |
${link} |
`;
- }).join("") || '| Чеков пока нет |
';
+ }).join("") || '| Чеков пока нет |
';
}
function renderSettings() {
@@ -230,9 +235,13 @@ function bindEvents() {
$("#reload-receipts").addEventListener("click", () => loadReceipts().then(() => toast("Чеки обновлены")).catch(err => toast(err.message)));
$("#sync-fns").addEventListener("click", async () => {
try {
- const result = await api("/admin/api/receipts/sync", { method: "POST", body: "{}" });
+ const month = $("#sync-month").value;
+ const result = await api("/admin/api/receipts/sync", {
+ method: "POST",
+ body: JSON.stringify({ month })
+ });
await loadReceipts();
- toast(`Синхронизировано из ФНС: ${result.synced}`);
+ toast(`ФНС ${result.month}: действует ${result.activeSynced}, аннулировано ${result.cancelledSynced}`);
} catch (err) {
toast(err.message);
}
@@ -301,4 +310,5 @@ function bindEvents() {
bindEvents();
addItemRow();
+$("#sync-month").value = new Date().toISOString().slice(0, 7);
bootstrap().catch(err => toast(err.message));
diff --git a/public/index.html b/public/index.html
index dad9a51..c884463 100644
--- a/public/index.html
+++ b/public/index.html
@@ -35,6 +35,7 @@
+
@@ -53,6 +54,7 @@
Пользователь |
Чек |
Сумма |
+ Статус |
Письмо |
Источник |
|
diff --git a/public/openapi.json b/public/openapi.json
index 82b8473..bdb2c26 100644
--- a/public/openapi.json
+++ b/public/openapi.json
@@ -157,7 +157,11 @@
"type": "string"
},
"status": {
- "type": "string"
+ "type": "string",
+ "enum": [
+ "created",
+ "cancelled"
+ ]
},
"emailSent": {
"type": [
@@ -174,6 +178,9 @@
"items": {
"$ref": "#/components/schemas/Item"
}
+ },
+ "cancelled": {
+ "type": "boolean"
}
},
"additionalProperties": true
@@ -242,6 +249,57 @@
"type": "number"
}
}
+ },
+ "SyncReceiptsRequest": {
+ "type": "object",
+ "properties": {
+ "month": {
+ "type": "string",
+ "pattern": "^\\d{4}-\\d{2}$",
+ "examples": [
+ "2026-06"
+ ],
+ "description": "Месяц выгрузки из ФНС в формате YYYY-MM"
+ }
+ }
+ },
+ "SyncReceiptsResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ },
+ "synced": {
+ "type": "number"
+ },
+ "activeSynced": {
+ "type": "number"
+ },
+ "cancelledSynced": {
+ "type": "number"
+ },
+ "total": {
+ "type": "number"
+ },
+ "month": {
+ "type": "string"
+ },
+ "from": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "to": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "storage": {
+ "type": "string",
+ "enum": [
+ "redis",
+ "file"
+ ]
+ }
+ }
}
}
},
@@ -451,6 +509,21 @@
"additionalProperties": {
"type": "string"
}
+ },
+ "storage": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "redis",
+ "file"
+ ]
+ },
+ "receiptsKey": {
+ "type": "string"
+ }
+ }
}
}
}
@@ -525,6 +598,13 @@
"type": "object",
"additionalProperties": true
}
+ },
+ "storage": {
+ "type": "string",
+ "enum": [
+ "redis",
+ "file"
+ ]
}
}
}
@@ -539,7 +619,7 @@
"tags": [
"Admin"
],
- "summary": "Синхронизировать последние чеки из ФНС",
+ "summary": "Синхронизировать чеки из ФНС за выбранный месяц",
"security": [
{
"AdminBearer": []
@@ -551,18 +631,7 @@
"content": {
"application/json": {
"schema": {
- "type": "object",
- "properties": {
- "success": {
- "type": "boolean"
- },
- "synced": {
- "type": "number"
- },
- "total": {
- "type": "number"
- }
- }
+ "$ref": "#/components/schemas/SyncReceiptsResponse"
}
}
}
@@ -570,6 +639,16 @@
"500": {
"description": "Ошибка синхронизации"
}
+ },
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SyncReceiptsRequest"
+ }
+ }
+ }
}
}
},
diff --git a/public/styles.css b/public/styles.css
index 5ea7bb4..945b0d3 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -152,6 +152,10 @@ main {
min-width: 220px;
}
+.month-input {
+ max-width: 170px;
+}
+
.metrics {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
diff --git a/server.js b/server.js
index d58d044..ccc2fef 100644
--- a/server.js
+++ b/server.js
@@ -6,6 +6,7 @@ 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();
@@ -38,16 +39,26 @@ const CONFIG_KEYS = [
"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"]);
+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;
@@ -83,6 +94,14 @@ 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();
@@ -122,6 +141,126 @@ async function loadRuntimeConfig() {
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");
@@ -339,8 +478,7 @@ async function saveToErrorFile(errorData) {
}
async function saveReceipt(receiptData) {
- const receipts = await readJsonFile(RECEIPTS_FILE, []);
- const nextReceipts = Array.isArray(receipts) ? receipts : [];
+ const nextReceipts = await readReceipts();
const existingIndex = nextReceipts.findIndex(item => item.receiptId === receiptData.receiptId);
const normalizedReceipt = {
...receiptData,
@@ -357,7 +495,7 @@ async function saveReceipt(receiptData) {
nextReceipts.unshift(normalizedReceipt);
}
- await writeJsonFile(RECEIPTS_FILE, nextReceipts);
+ await writeReceipts(nextReceipts);
}
function requireAdmin(req, res, next) {
@@ -396,15 +534,43 @@ 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: item.cancelTime || item.cancellationInfo ? "cancelled" : "created",
+ status: cancelled ? "cancelled" : "created",
+ cancelled,
emailSent: null,
createdAt: item.operationTime || item.requestTime || item.createdAt || null,
printLink: receiptId ? receiptPrintLink(receiptId) : "",
@@ -413,12 +579,28 @@ function normalizeFnsIncome(item) {
};
}
-async function getFnsReceipts({ offset = 0, limit = 50 } = {}) {
+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(),
@@ -433,18 +615,48 @@ async function getFnsReceipts({ offset = 0, limit = 50 } = {}) {
};
}
-async function syncFnsReceipts() {
- const fnsReceipts = await getFnsReceipts();
- const localReceipts = await readJsonFile(RECEIPTS_FILE, []);
- const merged = Array.isArray(localReceipts) ? [...localReceipts] : [];
+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,
- ...merged[existingIndex],
+ 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()
};
@@ -459,11 +671,17 @@ async function syncFnsReceipts() {
}
merged.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
- await writeJsonFile(RECEIPTS_FILE, merged);
+ await writeReceipts(merged);
return {
synced: fnsReceipts.items.length,
- total: merged.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"
};
}
@@ -620,6 +838,10 @@ app.get("/admin/api/config", requireAdmin, (req, res) => {
config: CONFIG_FILE,
receipts: RECEIPTS_FILE,
errors: ERROR_FILE
+ },
+ storage: {
+ type: hasRedisConfig() ? "redis" : "file",
+ receiptsKey: getRedisKey("receipts")
}
});
});
@@ -642,24 +864,26 @@ app.put("/admin/api/config", requireAdmin, async (req, res) => {
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 readJsonFile(RECEIPTS_FILE, []);
+ const receipts = await readReceipts();
const errors = await readJsonFile(ERROR_FILE, []);
res.json({
receipts: Array.isArray(receipts) ? receipts : [],
- errors: Array.isArray(errors) ? errors : []
+ errors: Array.isArray(errors) ? errors : [],
+ storage: hasRedisConfig() ? "redis" : "file"
});
});
app.post("/admin/api/receipts/sync", requireAdmin, async (req, res) => {
try {
- const result = await syncFnsReceipts();
+ const result = await syncFnsReceipts({ month: req.body?.month });
res.json({ success: true, ...result });
} catch (err) {
res.status(500).json({