diff --git a/README.MD b/README.MD
index d7eb8e8..68af3e2 100644
--- a/README.MD
+++ b/README.MD
@@ -137,3 +137,5 @@ Content-Type: application/json
Если чек создан в ФНС, но письмо клиенту не отправилось, API вернет `success: true`, `receiptCreated: true`, `emailSent: false`, `receiptId`, `printLink` и `technicalError`. Ошибка отправки сохранится в `error.json`.
Все успешно созданные чеки сохраняются в Redis, чтобы в UI была связь между чеком, email пользователя и позициями заказа. Синхронизация с ФНС выполняется за выбранный месяц, подтягивает страницы по 50 записей до конца месяца и помечает аннулированные чеки как `cancelled`. Аннулированные чеки показываются в списке, но не учитываются в количестве действующих чеков и итоговой сумме.
+
+В UI и API по каждому чеку рассчитываются суммы `grossAmount` (грязными), `taxAmount` (налог) и `netAmount` (чистыми). Для физлиц используется ставка 4%, для юрлиц 6%.
diff --git a/public/app.js b/public/app.js
index 44694b8..d555137 100644
--- a/public/app.js
+++ b/public/app.js
@@ -85,6 +85,30 @@ function formatMoney(value) {
});
}
+function normalizeClientType(value) {
+ return value === "legal" ? "legal" : "individual";
+}
+
+function taxRateForClientType(clientType) {
+ return normalizeClientType(clientType) === "legal" ? 0.06 : 0.04;
+}
+
+function receiptGross(receipt) {
+ return Number(receipt.grossAmount ?? receipt.amount ?? receipt.totalAmount ?? 0) || 0;
+}
+
+function receiptTaxRate(receipt) {
+ return Number(receipt.taxRate ?? taxRateForClientType(receipt.clientType));
+}
+
+function receiptTax(receipt) {
+ return Number(receipt.taxAmount ?? (receiptGross(receipt) * receiptTaxRate(receipt))) || 0;
+}
+
+function receiptNet(receipt) {
+ return Number(receipt.netAmount ?? (receiptGross(receipt) - receiptTax(receipt))) || 0;
+}
+
function receiptText(receipt) {
const itemText = (receipt.items || [])
.map(item => `${item.id || ""} ${item.name || item.title || ""}`)
@@ -96,14 +120,20 @@ function renderReceipts() {
const query = $("#receipt-search").value.trim().toLowerCase();
const filtered = state.receipts.filter(receipt => receiptText(receipt).includes(query));
const activeReceipts = filtered.filter(receipt => receipt.status !== "cancelled");
- const total = activeReceipts.reduce((sum, receipt) => sum + (Number(receipt.amount) || Number(receipt.totalAmount) || 0), 0);
+ const grossTotal = activeReceipts.reduce((sum, receipt) => sum + receiptGross(receipt), 0);
+ const taxTotal = activeReceipts.reduce((sum, receipt) => sum + receiptTax(receipt), 0);
+ const netTotal = activeReceipts.reduce((sum, receipt) => sum + receiptNet(receipt), 0);
$("#metric-count").textContent = activeReceipts.length;
- $("#metric-total").textContent = formatMoney(total);
+ $("#metric-gross").textContent = formatMoney(grossTotal);
+ $("#metric-tax").textContent = formatMoney(taxTotal);
+ $("#metric-net").textContent = formatMoney(netTotal);
$("#metric-errors").textContent = state.errors.length;
$("#receipts-body").innerHTML = filtered.map(receipt => {
- const amount = receipt.amount || receipt.totalAmount || 0;
+ const grossAmount = receiptGross(receipt);
+ const taxAmount = receiptTax(receipt);
+ const netAmount = receiptNet(receipt);
const emailBadge = receipt.emailSent === true
? 'отправлено'
: receipt.emailSent === false
@@ -113,6 +143,7 @@ function renderReceipts() {
const statusBadge = receipt.status === "cancelled"
? 'аннулирован'
: 'действует';
+ const clientTypeLabel = normalizeClientType(receipt.clientType) === "legal" ? "юр, 6%" : "физ, 4%";
const link = receipt.printLink
? `открыть`
: "";
@@ -123,16 +154,19 @@ function renderReceipts() {
${receipt.email || 'неизвестно'} |
${receipt.receiptId || 'нет ID'}
+ ${clientTypeLabel}
${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}
|
- ${formatMoney(amount)} ₽ |
+ ${formatMoney(grossAmount)} ₽ |
+ ${formatMoney(taxAmount)} ₽ |
+ ${formatMoney(netAmount)} ₽ |
${statusBadge} |
${emailBadge} |
${source} |
${link} |
`;
- }).join("") || '| Чеков пока нет |
';
+ }).join("") || '| Чеков пока нет |
';
}
function renderSettings() {
@@ -203,7 +237,11 @@ function currentItems() {
function updateCreateTotal() {
const total = currentItems().reduce((sum, item) => sum + item.price * item.quantity, 0);
+ const tax = total * taxRateForClientType($("#client-type").value);
+ const net = total - tax;
$("#create-total").textContent = formatMoney(total);
+ $("#create-tax").textContent = formatMoney(tax);
+ $("#create-net").textContent = formatMoney(net);
}
function showView(name) {
@@ -248,9 +286,11 @@ function bindEvents() {
});
$("#add-item").addEventListener("click", () => addItemRow());
+ $("#client-type").addEventListener("change", updateCreateTotal);
$("#create-form").addEventListener("submit", async event => {
event.preventDefault();
const email = new FormData(event.currentTarget).get("email");
+ const clientType = new FormData(event.currentTarget).get("clientType");
const items = currentItems();
if (!items.length) {
toast("Добавьте хотя бы одну позицию с ценой");
@@ -259,7 +299,7 @@ function bindEvents() {
try {
const result = await api("/api/v1/create-receipt", {
method: "POST",
- body: JSON.stringify({ email, items })
+ body: JSON.stringify({ email, clientType, items })
});
await loadReceipts();
toast(result.emailSent ? "Чек создан и отправлен" : "Чек создан, но письмо не ушло");
diff --git a/public/index.html b/public/index.html
index c884463..c42b6e8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -42,7 +42,9 @@
0чеков
-
0.00рублей
+
0.00грязными
+
0.00налог
+
0.00чистыми
0ошибок
@@ -53,7 +55,9 @@
Дата |
Пользователь |
Чек |
- Сумма |
+ Грязными |
+ Налог |
+ Чистыми |
Статус |
Письмо |
Источник |
@@ -71,13 +75,22 @@
Email пользователя
+
Позиции
diff --git a/public/openapi.json b/public/openapi.json
index bdb2c26..b4b50d5 100644
--- a/public/openapi.json
+++ b/public/openapi.json
@@ -110,6 +110,15 @@
"items": {
"$ref": "#/components/schemas/Item"
}
+ },
+ "clientType": {
+ "type": "string",
+ "enum": [
+ "individual",
+ "legal"
+ ],
+ "default": "individual",
+ "description": "individual = физлицо 4%, legal = юрлицо 6%"
}
}
},
@@ -138,6 +147,33 @@
"technicalError": {
"type": "object",
"additionalProperties": true
+ },
+ "clientType": {
+ "type": "string",
+ "enum": [
+ "individual",
+ "legal"
+ ],
+ "description": "individual = физлицо 4%, legal = юрлицо 6%"
+ },
+ "grossAmount": {
+ "type": "number",
+ "description": "Сумма грязными, до налога"
+ },
+ "taxRate": {
+ "type": "number",
+ "examples": [
+ 0.04,
+ 0.06
+ ]
+ },
+ "taxAmount": {
+ "type": "number",
+ "description": "Сумма налога"
+ },
+ "netAmount": {
+ "type": "number",
+ "description": "Сумма чистыми, после налога"
}
}
},
@@ -181,6 +217,33 @@
},
"cancelled": {
"type": "boolean"
+ },
+ "clientType": {
+ "type": "string",
+ "enum": [
+ "individual",
+ "legal"
+ ],
+ "description": "individual = физлицо 4%, legal = юрлицо 6%"
+ },
+ "grossAmount": {
+ "type": "number",
+ "description": "Сумма грязными, до налога"
+ },
+ "taxRate": {
+ "type": "number",
+ "examples": [
+ 0.04,
+ 0.06
+ ]
+ },
+ "taxAmount": {
+ "type": "number",
+ "description": "Сумма налога"
+ },
+ "netAmount": {
+ "type": "number",
+ "description": "Сумма чистыми, после налога"
}
},
"additionalProperties": true
diff --git a/public/styles.css b/public/styles.css
index 945b0d3..90e68b0 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -158,7 +158,7 @@ main {
.metrics {
display: grid;
- grid-template-columns: repeat(3, minmax(120px, 1fr));
+ grid-template-columns: repeat(5, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 14px;
}
@@ -280,6 +280,7 @@ td a {
.form-footer {
justify-content: space-between;
+ flex-wrap: wrap;
}
.settings-head {
diff --git a/server.js b/server.js
index ccc2fef..0da2a11 100644
--- a/server.js
+++ b/server.js
@@ -102,6 +102,53 @@ function getRedisKey(name) {
return `${getConfig("REDIS_KEY_PREFIX", "fns-receipt-service")}:${name}`;
}
+function normalizeClientType(value) {
+ const normalized = String(value || "").toLowerCase();
+ if (
+ normalized === "legal" ||
+ normalized === "juridical" ||
+ normalized === "company" ||
+ normalized === "organization" ||
+ normalized.includes("legal") ||
+ normalized.includes("юр") ||
+ normalized.includes("from_legal")
+ ) {
+ return "legal";
+ }
+
+ return "individual";
+}
+
+function taxRateForClientType(clientType) {
+ return normalizeClientType(clientType) === "legal" ? 0.06 : 0.04;
+}
+
+function receiptAmount(receipt) {
+ return Number(receipt.grossAmount ?? receipt.amount ?? receipt.totalAmount ?? 0) || 0;
+}
+
+function withReceiptFinancials(receipt) {
+ const clientType = normalizeClientType(
+ receipt.clientType ||
+ receipt.raw?.client?.incomeType ||
+ receipt.raw?.incomeInfo?.client?.incomeType
+ );
+ const grossAmount = Number(receiptAmount(receipt).toFixed(2));
+ const taxRate = taxRateForClientType(clientType);
+ const taxAmount = Number((grossAmount * taxRate).toFixed(2));
+ const netAmount = Number((grossAmount - taxAmount).toFixed(2));
+
+ return {
+ ...receipt,
+ clientType,
+ grossAmount,
+ taxRate,
+ taxAmount,
+ netAmount,
+ amount: grossAmount
+ };
+}
+
function createTransporter() {
const smtpTimeoutMs = getSmtpTimeoutMs();
@@ -217,7 +264,7 @@ async function readReceipts() {
try {
const client = await getRedisClient();
- if (!client) return await fallbackReceipts();
+ if (!client) return (await fallbackReceipts()).map(withReceiptFinancials);
const value = await withTimeout(
client.get(getRedisKey("receipts")),
@@ -230,19 +277,19 @@ async function readReceipts() {
if (localReceipts.length > 0) {
await writeReceipts(localReceipts);
}
- return localReceipts;
+ return localReceipts.map(withReceiptFinancials);
}
const parsed = JSON.parse(value);
- return Array.isArray(parsed) ? parsed : [];
+ return Array.isArray(parsed) ? parsed.map(withReceiptFinancials) : [];
} catch (err) {
console.error("Не удалось прочитать чеки из Redis, используется локальный файл:", err.message || err);
- return await fallbackReceipts();
+ return (await fallbackReceipts()).map(withReceiptFinancials);
}
}
async function writeReceipts(receipts) {
- const normalizedReceipts = Array.isArray(receipts) ? receipts : [];
+ const normalizedReceipts = Array.isArray(receipts) ? receipts.map(withReceiptFinancials) : [];
try {
const client = await getRedisClient();
@@ -485,14 +532,15 @@ async function saveReceipt(receiptData) {
createdAt: receiptData.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
};
+ const receiptWithFinancials = withReceiptFinancials(normalizedReceipt);
if (existingIndex >= 0) {
nextReceipts[existingIndex] = {
...nextReceipts[existingIndex],
- ...normalizedReceipt
+ ...receiptWithFinancials
};
} else {
- nextReceipts.unshift(normalizedReceipt);
+ nextReceipts.unshift(receiptWithFinancials);
}
await writeReceipts(nextReceipts);
@@ -563,12 +611,15 @@ 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);
+ const clientType = normalizeClientType(item.client?.incomeType || item.incomeInfo?.client?.incomeType || item.incomeType);
- return {
+ return withReceiptFinancials({
source: "fns",
receiptId,
email: "",
amount,
+ grossAmount: amount,
+ clientType,
status: cancelled ? "cancelled" : "created",
cancelled,
emailSent: null,
@@ -576,7 +627,7 @@ function normalizeFnsIncome(item) {
printLink: receiptId ? receiptPrintLink(receiptId) : "",
items: item.services || [],
raw: item
- };
+ });
}
function monthRange(month) {
@@ -655,6 +706,7 @@ async function syncFnsReceipts({ month } = {}) {
...existingReceipt,
...fnsReceipt,
email: existingReceipt.email || fnsReceipt.email,
+ clientType: existingReceipt.clientType || fnsReceipt.clientType,
emailSent: existingReceipt.emailSent ?? fnsReceipt.emailSent,
items: existingReceipt.items?.length ? existingReceipt.items : fnsReceipt.items,
fns: fnsReceipt.raw,
@@ -670,14 +722,15 @@ async function syncFnsReceipts({ month } = {}) {
}
}
- merged.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
- await writeReceipts(merged);
+ const receiptsWithFinancials = merged.map(withReceiptFinancials);
+ receiptsWithFinancials.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
+ await writeReceipts(receiptsWithFinancials);
return {
synced: fnsReceipts.items.length,
activeSynced: fnsReceipts.items.filter(item => item.status !== "cancelled").length,
cancelledSynced: fnsReceipts.items.filter(item => item.status === "cancelled").length,
- total: merged.length,
+ total: receiptsWithFinancials.length,
month: targetMonth,
from: fnsReceipts.from,
to: fnsReceipts.to,
@@ -908,6 +961,7 @@ app.get("/admin/api/fns-user", requireAdmin, async (req, res) => {
app.post("/api/v1/create-receipt", async (req, res) => {
try {
const { email, items } = req.body;
+ const clientType = normalizeClientType(req.body?.clientType);
if (!hasReceiptAccess(req)) {
return res.status(401).json({ error: "Unauthorized" });
@@ -1019,6 +1073,7 @@ ${getConfig("APPNAME")}
email,
items,
amount: total,
+ clientType,
printLink,
status: "created",
emailSent: false,
@@ -1032,7 +1087,8 @@ ${getConfig("APPNAME")}
receiptId,
printLink,
warning: "Чек создан в ФНС, но письмо клиенту не отправлено. Данные сохранены в error.json.",
- technicalError
+ technicalError,
+ ...withReceiptFinancials({ amount: total, clientType })
});
}
@@ -1041,6 +1097,7 @@ ${getConfig("APPNAME")}
email,
items,
amount: total,
+ clientType,
printLink,
status: "created",
emailSent: true
@@ -1051,7 +1108,8 @@ ${getConfig("APPNAME")}
receiptCreated: true,
emailSent: true,
receiptId,
- printLink
+ printLink,
+ ...withReceiptFinancials({ amount: total, clientType })
});
} catch (err) {