fix #8

Merged
romtuck merged 1 commits from dd into main 2026-06-09 10:31:25 +03:00
3 changed files with 421 additions and 7 deletions

View File

@ -106,20 +106,44 @@ fns-receipt-service:receipts
## API
Документация доступна в Swagger UI:
```http
GET /swagger
```
Авторизация API поддерживает два способа:
- `Authorization: Bearer <token>` после логина через `POST /api/v1/auth/login`
- `x-api-key: <API_PASS>` для серверных интеграций
Для совместимости `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`. Аннулированные чеки показываются в списке, но не учитываются в количестве действующих чеков и итоговой сумме.

View File

@ -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": [

103
server.js
View File

@ -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");