fix #6

Merged
romtuck merged 1 commits from dd into main 2026-06-08 19:01:25 +03:00
9 changed files with 483 additions and 38 deletions

View File

@ -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 # Таймаут создания чека в ФНС

View File

@ -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`. Аннулированные чеки показываются в списке, но не учитываются в количестве действующих чеков и итоговой сумме.

100
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -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() {
? '<span class="badge bad">ошибка</span>'
: '<span class="badge warn">нет данных</span>';
const source = receipt.syncedFromFns ? "ФНС" : "локально";
const statusBadge = receipt.status === "cancelled"
? '<span class="badge bad">аннулирован</span>'
: '<span class="badge ok">действует</span>';
const link = receipt.printLink
? `<a href="${receipt.printLink}" target="_blank" rel="noreferrer">открыть</a>`
: "";
@ -122,12 +126,13 @@ function renderReceipts() {
<div class="muted">${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}</div>
</td>
<td>${formatMoney(amount)} </td>
<td>${statusBadge}</td>
<td>${emailBadge}</td>
<td><span class="badge">${source}</span></td>
<td>${link}</td>
</tr>
`;
}).join("") || '<tr><td colspan="7" class="muted">Чеков пока нет</td></tr>';
}).join("") || '<tr><td colspan="8" class="muted">Чеков пока нет</td></tr>';
}
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));

View File

@ -35,6 +35,7 @@
<div class="search">
<input id="receipt-search" type="search" placeholder="Поиск по email, ID чека или позиции">
</div>
<input id="sync-month" class="month-input" type="month" aria-label="Месяц синхронизации">
<button id="reload-receipts" type="button">Обновить</button>
<button id="sync-fns" type="button">Синхронизировать с ФНС</button>
</div>
@ -53,6 +54,7 @@
<th>Пользователь</th>
<th>Чек</th>
<th>Сумма</th>
<th>Статус</th>
<th>Письмо</th>
<th>Источник</th>
<th></th>

View File

@ -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"
}
}
}
}
}
},

View File

@ -152,6 +152,10 @@ main {
min-width: 220px;
}
.month-input {
max-width: 170px;
}
.metrics {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));

256
server.js
View File

@ -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({