fix #8
52
README.MD
52
README.MD
@ -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`. Аннулированные чеки показываются в списке, но не учитываются в количестве действующих чеков и итоговой сумме.
|
||||
|
||||
@ -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
103
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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user