fix #8
52
README.MD
52
README.MD
@ -106,20 +106,44 @@ fns-receipt-service:receipts
|
|||||||
|
|
||||||
## API
|
## 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
|
```http
|
||||||
GET /health
|
GET /health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Получить JWT:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"password": "strong_api_password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Создание чека:
|
Создание чека:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /api/v1/create-receipt
|
POST /api/v1/create-receipt
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
X-Api-Key: strong_api_password
|
||||||
|
|
||||||
{
|
{
|
||||||
"api_pass": "strong_api_password",
|
|
||||||
"email": "client@example.com",
|
"email": "client@example.com",
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
@ -134,6 +158,32 @@ Content-Type: application/json
|
|||||||
|
|
||||||
Поле `email` обязательно: на этот адрес сервис отправит письмо со ссылкой на чек после успешного создания чека в ФНС.
|
Поле `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`.
|
Если чек создан в ФНС, но письмо клиенту не отправилось, API вернет `success: true`, `receiptCreated: true`, `emailSent: false`, `receiptId`, `printLink` и `technicalError`. Ошибка отправки сохранится в `error.json`.
|
||||||
|
|
||||||
Все успешно созданные чеки сохраняются в Redis, чтобы в UI была связь между чеком, email пользователя и позициями заказа. Синхронизация с ФНС выполняется за выбранный месяц, подтягивает страницы по 50 записей до конца месяца и помечает аннулированные чеки как `cancelled`. Аннулированные чеки показываются в списке, но не учитываются в количестве действующих чеков и итоговой сумме.
|
Все успешно созданные чеки сохраняются в Redis, чтобы в UI была связь между чеком, email пользователя и позициями заказа. Синхронизация с ФНС выполняется за выбранный месяц, подтягивает страницы по 50 записей до конца месяца и помечает аннулированные чеки как `cancelled`. Аннулированные чеки показываются в списке, но не учитываются в количестве действующих чеков и итоговой сумме.
|
||||||
|
|||||||
@ -18,7 +18,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Receipts",
|
"name": "Receipts",
|
||||||
"description": "Создание чеков"
|
"description": "Создание, чтение и синхронизация чеков"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Auth",
|
||||||
|
"description": "Авторизация API и админ-панели"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Admin",
|
"name": "Admin",
|
||||||
@ -36,8 +40,8 @@
|
|||||||
"AdminPassword": {
|
"AdminPassword": {
|
||||||
"type": "apiKey",
|
"type": "apiKey",
|
||||||
"in": "header",
|
"in": "header",
|
||||||
"name": "x-admin-password",
|
"name": "x-api-key",
|
||||||
"description": "Legacy: значение API_PASS"
|
"description": "Значение API_PASS. Для совместимости также поддерживается x-admin-password и api_pass в JSON body."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemas": {
|
"schemas": {
|
||||||
@ -248,6 +252,52 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": true
|
"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": {
|
"ConfigItem": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -517,10 +567,225 @@
|
|||||||
{
|
{
|
||||||
"AdminBearer": []
|
"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": {
|
"/admin/api/session": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
103
server.js
103
server.js
@ -450,6 +450,10 @@ function getBearerToken(req) {
|
|||||||
return scheme?.toLowerCase() === "bearer" ? token : "";
|
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) {
|
function verifyAdminPassword(password) {
|
||||||
const expectedPassword = getConfig("API_PASS");
|
const expectedPassword = getConfig("API_PASS");
|
||||||
return Boolean(expectedPassword && password && safeEqual(password, expectedPassword));
|
return Boolean(expectedPassword && password && safeEqual(password, expectedPassword));
|
||||||
@ -557,7 +561,7 @@ function requireAdmin(req, res, next) {
|
|||||||
return 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" });
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -565,7 +569,19 @@ function requireAdmin(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasReceiptAccess(req) {
|
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() {
|
function publicConfig() {
|
||||||
@ -582,6 +598,36 @@ function receiptPrintLink(receiptId) {
|
|||||||
return `https://lknpd.nalog.ru/api/v1/receipt/${getConfig("INN")}/${receiptId}/print`;
|
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) {
|
function isCancelledFnsIncome(item) {
|
||||||
const values = [
|
const values = [
|
||||||
item.status,
|
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) => {
|
app.post("/admin/api/session", requireAdmin, (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
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) => {
|
app.post("/admin/api/receipts/sync", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await syncFnsReceipts({ month: req.body?.month });
|
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) => {
|
app.get("/admin/api/fns-user", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await withTimeout(getNalogApi().getUserInfo(), getFnsTimeoutMs(), "FNS user info");
|
const user = await withTimeout(getNalogApi().getUserInfo(), getFnsTimeoutMs(), "FNS user info");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user