diff --git a/README.MD b/README.MD index 68af3e2..5578b1f 100644 --- a/README.MD +++ b/README.MD @@ -106,20 +106,44 @@ fns-receipt-service:receipts ## API +Документация доступна в Swagger UI: + +```http +GET /swagger +``` + +Авторизация API поддерживает два способа: + +- `Authorization: Bearer ` после логина через `POST /api/v1/auth/login` +- `x-api-key: ` для серверных интеграций + +Для совместимости `POST /api/v1/create-receipt` также принимает `api_pass` в JSON body. + Проверка состояния: ```http GET /health ``` +Получить JWT: + +```http +POST /api/v1/auth/login +Content-Type: application/json + +{ + "password": "strong_api_password" +} +``` + Создание чека: ```http POST /api/v1/create-receipt Content-Type: application/json +X-Api-Key: strong_api_password { - "api_pass": "strong_api_password", "email": "client@example.com", "items": [ { @@ -134,6 +158,32 @@ Content-Type: application/json Поле `email` обязательно: на этот адрес сервис отправит письмо со ссылкой на чек после успешного создания чека в ФНС. +Получить журнал чеков: + +```http +GET /api/v1/receipts?limit=50&offset=0&status=created&clientType=individual&search=client@example.com +X-Api-Key: strong_api_password +``` + +Получить один чек: + +```http +GET /api/v1/receipts/{receiptId} +X-Api-Key: strong_api_password +``` + +Синхронизировать чеки из ФНС за месяц: + +```http +POST /api/v1/receipts/sync +Content-Type: application/json +X-Api-Key: strong_api_password + +{ + "month": "2026-06" +} +``` + Если чек создан в ФНС, но письмо клиенту не отправилось, API вернет `success: true`, `receiptCreated: true`, `emailSent: false`, `receiptId`, `printLink` и `technicalError`. Ошибка отправки сохранится в `error.json`. Все успешно созданные чеки сохраняются в Redis, чтобы в UI была связь между чеком, email пользователя и позициями заказа. Синхронизация с ФНС выполняется за выбранный месяц, подтягивает страницы по 50 записей до конца месяца и помечает аннулированные чеки как `cancelled`. Аннулированные чеки показываются в списке, но не учитываются в количестве действующих чеков и итоговой сумме. diff --git a/public/openapi.json b/public/openapi.json index b4b50d5..d1118c3 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -18,7 +18,11 @@ }, { "name": "Receipts", - "description": "Создание чеков" + "description": "Создание, чтение и синхронизация чеков" + }, + { + "name": "Auth", + "description": "Авторизация API и админ-панели" }, { "name": "Admin", @@ -36,8 +40,8 @@ "AdminPassword": { "type": "apiKey", "in": "header", - "name": "x-admin-password", - "description": "Legacy: значение API_PASS" + "name": "x-api-key", + "description": "Значение API_PASS. Для совместимости также поддерживается x-admin-password и api_pass в JSON body." } }, "schemas": { @@ -248,6 +252,52 @@ }, "additionalProperties": true }, + "ReceiptListResponse": { + "type": "object", + "properties": { + "receipts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Receipt" + } + }, + "pagination": { + "type": "object", + "properties": { + "limit": { + "type": "number", + "examples": [ + 50 + ] + }, + "offset": { + "type": "number", + "examples": [ + 0 + ] + }, + "total": { + "type": "number" + } + } + }, + "storage": { + "type": "string", + "enum": [ + "redis", + "file" + ] + } + } + }, + "ReceiptResponse": { + "type": "object", + "properties": { + "receipt": { + "$ref": "#/components/schemas/Receipt" + } + } + }, "ConfigItem": { "type": "object", "properties": { @@ -517,10 +567,225 @@ { "AdminBearer": [] }, - {} + { + "AdminPassword": [] + } ] } }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Получить JWT для API и админ-панели", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + }, + "examples": { + "password": { + "value": { + "password": "strong_api_password" + } + }, + "legacy": { + "value": { + "api_pass": "strong_api_password" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "JWT создан", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "401": { + "description": "Неверный пароль" + } + } + } + }, + "/api/v1/receipts": { + "get": { + "tags": [ + "Receipts" + ], + "summary": "Получить журнал чеков", + "security": [ + { + "AdminBearer": [] + }, + { + "AdminPassword": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "number", + "default": 50, + "maximum": 200 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "number", + "default": 0 + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "created", + "cancelled" + ] + } + }, + { + "name": "clientType", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "individual", + "legal" + ] + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Поиск по email, ID чека и позициям" + } + ], + "responses": { + "200": { + "description": "Список чеков", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptListResponse" + } + } + } + }, + "401": { + "description": "Нет доступа" + } + } + } + }, + "/api/v1/receipts/{receiptId}": { + "get": { + "tags": [ + "Receipts" + ], + "summary": "Получить чек по ID", + "security": [ + { + "AdminBearer": [] + }, + { + "AdminPassword": [] + } + ], + "parameters": [ + { + "name": "receiptId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Чек найден", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReceiptResponse" + } + } + } + }, + "401": { + "description": "Нет доступа" + }, + "404": { + "description": "Чек не найден" + } + } + } + }, + "/api/v1/receipts/sync": { + "post": { + "tags": [ + "Receipts" + ], + "summary": "Синхронизировать чеки из ФНС за выбранный месяц", + "security": [ + { + "AdminBearer": [] + }, + { + "AdminPassword": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncReceiptsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Синхронизация выполнена", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncReceiptsResponse" + } + } + } + }, + "401": { + "description": "Нет доступа" + }, + "500": { + "description": "Ошибка синхронизации" + } + } + } + }, "/admin/api/session": { "post": { "tags": [ diff --git a/server.js b/server.js index 0da2a11..9101bd2 100644 --- a/server.js +++ b/server.js @@ -450,6 +450,10 @@ function getBearerToken(req) { return scheme?.toLowerCase() === "bearer" ? token : ""; } +function getApiPassword(req) { + return req.get("x-api-key") || req.get("x-admin-password") || req.body?.api_pass || ""; +} + function verifyAdminPassword(password) { const expectedPassword = getConfig("API_PASS"); return Boolean(expectedPassword && password && safeEqual(password, expectedPassword)); @@ -557,7 +561,7 @@ function requireAdmin(req, res, next) { return next(); } - if (!verifyAdminPassword(req.get("x-admin-password") || req.body?.api_pass)) { + if (!verifyAdminPassword(getApiPassword(req))) { return res.status(401).json({ error: "Unauthorized" }); } @@ -565,7 +569,19 @@ function requireAdmin(req, res, next) { } function hasReceiptAccess(req) { - return verifyJwt(getBearerToken(req)) || verifyAdminPassword(req.body?.api_pass); + return verifyJwt(getBearerToken(req)) || verifyAdminPassword(getApiPassword(req)); +} + +function requireApiAccess(req, res, next) { + if (!getConfig("API_PASS")) { + return res.status(500).json({ error: "API_PASS is not configured" }); + } + + if (!hasReceiptAccess(req)) { + return res.status(401).json({ error: "Unauthorized" }); + } + + next(); } function publicConfig() { @@ -582,6 +598,36 @@ function receiptPrintLink(receiptId) { return `https://lknpd.nalog.ru/api/v1/receipt/${getConfig("INN")}/${receiptId}/print`; } +function filterReceipts(receipts, query = {}) { + const status = String(query.status || "").trim().toLowerCase(); + const clientType = String(query.clientType || "").trim().toLowerCase(); + const search = String(query.search || "").trim().toLowerCase(); + + return receipts.filter(receipt => { + if (status && String(receipt.status || "").toLowerCase() !== status) return false; + if (clientType && normalizeClientType(receipt.clientType) !== normalizeClientType(clientType)) return false; + if (!search) return true; + + const itemText = (receipt.items || []) + .map(item => `${item.id || ""} ${item.name || item.title || ""}`) + .join(" "); + const text = `${receipt.email || ""} ${receipt.receiptId || ""} ${itemText}`.toLowerCase(); + return text.includes(search); + }); +} + +function paginate(items, query = {}) { + const limit = Math.min(Math.max(Number(query.limit) || 50, 1), 200); + const offset = Math.max(Number(query.offset) || 0, 0); + + return { + items: items.slice(offset, offset + limit), + limit, + offset, + total: items.length + }; +} + function isCancelledFnsIncome(item) { const values = [ item.status, @@ -877,6 +923,21 @@ app.post("/admin/api/login", (req, res) => { }); }); +app.post("/api/v1/auth/login", (req, res) => { + if (!getConfig("API_PASS")) { + return res.status(500).json({ error: "API_PASS is not configured" }); + } + + if (!verifyAdminPassword(req.body?.password || req.body?.api_pass)) { + return res.status(401).json({ error: "Unauthorized" }); + } + + res.json({ + success: true, + ...createAdminToken() + }); +}); + app.post("/admin/api/session", requireAdmin, (req, res) => { res.json({ success: true, @@ -934,6 +995,32 @@ app.get("/admin/api/receipts", requireAdmin, async (req, res) => { }); }); +app.get("/api/v1/receipts", requireApiAccess, async (req, res) => { + const receipts = filterReceipts(await readReceipts(), req.query); + const page = paginate(receipts, req.query); + + res.json({ + receipts: page.items, + pagination: { + limit: page.limit, + offset: page.offset, + total: page.total + }, + storage: hasRedisConfig() ? "redis" : "file" + }); +}); + +app.get("/api/v1/receipts/:receiptId", requireApiAccess, async (req, res) => { + const receipts = await readReceipts(); + const receipt = receipts.find(item => item.receiptId === req.params.receiptId); + + if (!receipt) { + return res.status(404).json({ error: "Receipt not found" }); + } + + res.json({ receipt }); +}); + app.post("/admin/api/receipts/sync", requireAdmin, async (req, res) => { try { const result = await syncFnsReceipts({ month: req.body?.month }); @@ -946,6 +1033,18 @@ app.post("/admin/api/receipts/sync", requireAdmin, async (req, res) => { } }); +app.post("/api/v1/receipts/sync", requireApiAccess, 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");