# Импорт выписки (POST /api/import/statement) ## Назначение Эндпоинт принимает банковскую выписку (PDF или JSON) и атомарно импортирует транзакции в БД. ## Метод и URL - Метод: `POST` - URL: `/api/import/statement` ## Требования к авторизации - Требуется действующая сессия (cookie `sid`). - При отсутствии или недействительной сессии — `401 Unauthorized`. ## Тело запроса **Content-Type:** `multipart/form-data`. Поле: `file`. Допустимые типы файлов: - **PDF** — банковская выписка. Конвертируется в JSON 1.0 через LLM (требуется `LLM_API_KEY`). - **JSON** — файл по схеме 1.0 (см. `format.md`). При другом типе файла — `400 Bad Request`: «Допустимы только файлы PDF или JSON». Пример структуры JSON 1.0: ```json { "schemaVersion": "1.0", "bank": "VTB", "statement": { "accountNumber": "40817810825104025611", "currency": "RUB", "openingBalance": 4256167, "closingBalance": 8845938, "exportedAt": "2026-02-27T13:23:00+03:00" }, "transactions": [ { "operationAt": "2026-02-26T14:06:57+03:00", "amountSigned": -50000, "commission": 0, "description": "Оплата товаров и услуг. PAVELETSKAYA. по карте *8214" } ] } ``` ## Валидация Валидация выполняется в два этапа. ### 1) Структурная валидация (400 Bad Request) Проверяется формат входного JSON. При ошибке — `400` без частичного импорта. Проверки: - Тело запроса является валидным JSON. - Присутствуют все обязательные корневые поля: `schemaVersion`, `bank`, `statement`, `transactions`. - `schemaVersion` строго равен `"1.0"`. - `bank` — непустая строка. - `statement` содержит обязательные поля: `accountNumber`, `currency`, `openingBalance`, `closingBalance`, `exportedAt`. - `statement.accountNumber` — непустая строка. - `statement.currency` — непустая строка. - `statement.openingBalance`, `statement.closingBalance` — числа (integer). - `statement.exportedAt` — строка в формате ISO 8601 с обязательным offset (`+HH:MM` или `Z`). - `transactions` — массив, содержащий хотя бы один элемент. - Каждая транзакция содержит обязательные поля: `operationAt`, `amountSigned`, `commission`, `description`. - `operationAt` — строка в формате ISO 8601 с обязательным offset (`+HH:MM` или `Z`). - `amountSigned` — число (integer), не равное `0`. - `commission` — число (integer), `>= 0`. - `description` — непустая строка. Ответ при ошибке: ```json { "error": "BAD_REQUEST", "message": "Invalid schema: missing field 'statement.accountNumber'" } ``` ### 2) Семантическая валидация (422 Unprocessable Entity) Проверяется логическая корректность данных. При ошибке — `422`, весь файл откатывается. Проверки: - `statement.currency` соответствует допустимому коду валюты (MVP: `"RUB"`). - `operationAt` у всех транзакций — валидная дата (парсится без ошибок). - Отсутствуют дубликаты fingerprint внутри одного файла. Ответ при ошибке: ```json { "error": "VALIDATION_ERROR", "message": "Duplicate fingerprint found within file at transaction index 5" } ``` ## Логика обработки ### Счёт (account) 1. По комбинации `bank` + `statement.accountNumber` выполняется поиск в таблице `accounts`. 2. Если счёт найден — используется его `id`. 3. Если счёт не найден — создаётся новая запись: - `bank` = значение из JSON; - `account_number` = `statement.accountNumber`; - `currency` = `statement.currency`; - `alias` = `NULL`. 4. Флаг `isNewAccount` в ответе отражает, был ли счёт создан при этом импорте. ### Fingerprint Для каждой транзакции вычисляется SHA-256 от полей, соединённых разделителем `|`: ```text accountNumber|operationAt|amountSigned|commission|normalizedDescription ``` - `normalizedDescription` — `description` после `trim`. - Суммы подставляются в том виде, в котором пришли в JSON (числовое представление). - Разделитель `|` исключает коллизии при склейке полей разной длины. Результат хэширования хранится в поле `transactions.fingerprint` с префиксом `sha256:`. ### Direction Направление операции определяется по следующим правилам: 1. Проверка ключевых фраз в `description` (регистронезависимо): - Если содержит одну из фраз: `"Перевод между своими счетами"`, `"перевод средств на счет"`, `"Внутри ВТБ"` — `direction = "transfer"`. 2. Если ключевые фразы не сработали: - `amountSigned > 0` → `direction = "income"`; - `amountSigned < 0` → `direction = "expense"`. Список ключевых фраз для `"transfer"` может расширяться; в MVP используется фиксированный набор. ### Импорт транзакций Для каждой транзакции из массива `transactions`: 1. Суммы `amountSigned` и `commission` записываются как есть — в JSON 1.0 они уже в копейках (`BIGINT`). 2. Вычисляется `fingerprint`. 3. Определяется `direction`. 4. Выполняется вставка в таблицу `transactions` со значениями: - `account_id` — ID найденного/созданного счёта; - `operation_at` — из `operationAt`; - `amount_signed` — в копейках; - `commission` — в копейках; - `description` — как есть из JSON; - `direction` — вычисленное значение; - `fingerprint` — вычисленный хэш; - `category_id = NULL`; - `is_category_confirmed = FALSE`. 5. При срабатывании уникального ограничения `(account_id, fingerprint)` запись считается дубликатом и пропускается (счётчик `duplicatesSkipped` увеличивается). ### Автокатегоризация После успешной вставки всех транзакций backend применяет активные правила из `category_rules` к только что импортированным транзакциям (у которых `category_id IS NULL`): - Для каждой транзакции ищутся все подходящие активные правила. - При совпадении нескольких правил выбирается правило с максимальным `priority`. - Устанавливается `category_id` из правила. - Если у правила `requires_confirmation = FALSE` → `is_category_confirmed = TRUE`. - Если у правила `requires_confirmation = TRUE` → `is_category_confirmed = FALSE`. ### Атомарность Весь импорт выполняется в одной транзакции БД: - Создание счёта (если нужно) + вставка всех транзакций + автокатегоризация. - При любой ошибке валидации или вставки — откат всей транзакции БД, возвращается `422`. ## Успешный ответ (200 OK) ```json { "accountId": 1, "isNewAccount": true, "accountNumberMasked": "408178******5611", "imported": 28, "duplicatesSkipped": 3, "totalInFile": 31 } ``` Поля: - `accountId: number` — ID счёта в системе. - `isNewAccount: boolean` — `true`, если счёт был создан при этом импорте. - `accountNumberMasked: string` — маскированный номер счёта (первые 6 и последние 4 символа, остальное заменено на `*`). - `imported: number` — количество успешно импортированных (новых) транзакций. - `duplicatesSkipped: number` — количество пропущенных дубликатов. - `totalInFile: number` — общее количество транзакций в файле (`imported + duplicatesSkipped`). ## Маскирование номера счёта Номер счёта маскируется для отображения в UI: - Первые 6 символов остаются открытыми. - Последние 4 символа остаются открытыми. - Промежуточные символы заменяются фиксированным набором из 6 символов `******` (количество звёздочек не раскрывает длину номера). - Пример: `40817810825104025611` → `408178******5611`. Маскирование применяется в ответе этого эндпоинта и в `GET /api/accounts`. ## Коды ошибок | Код | Ситуация | |-----|--------------------------------------------------------------------------| | 200 | Импорт выполнен успешно | | 400 | Файл не загружен; неверный тип (не PDF и не JSON); некорректный JSON; ошибка извлечения текста из PDF | | 401 | Нет действующей сессии | | 422 | Семантическая ошибка валидации; результат конвертации PDF не соответствует схеме 1.0 | | 502 | Ошибка LLM при конвертации PDF | | 503 | Конвертация PDF недоступна (не задан LLM_API_KEY) |