Files
family_budget/docs/backlog/api_import.md
Anton 975f2c4fd2 feat: adds PDF import with conversion to JSON 1.0
- Accept only PDF and JSON files in import modal and API
- Convert PDF statements to JSON 1.0 via LLM (OpenAI-compatible)
- Use multipart/form-data for file upload (multer, 15 MB limit)
- Add LLM_API_KEY and LLM_API_BASE_URL for configurable LLM endpoint
- Update ImportModal to validate type and send FormData
- Add postFormData to API client for file upload
2026-03-13 13:38:02 +03:00

11 KiB
Raw Permalink Blame History

Импорт выписки (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:

{
  "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 — непустая строка.

Ответ при ошибке:

{
  "error": "BAD_REQUEST",
  "message": "Invalid schema: missing field 'statement.accountNumber'"
}

2) Семантическая валидация (422 Unprocessable Entity)

Проверяется логическая корректность данных. При ошибке — 422, весь файл откатывается.

Проверки:

  • statement.currency соответствует допустимому коду валюты (MVP: "RUB").
  • operationAt у всех транзакций — валидная дата (парсится без ошибок).
  • Отсутствуют дубликаты fingerprint внутри одного файла.

Ответ при ошибке:

{
  "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 от полей, соединённых разделителем |:

accountNumber|operationAt|amountSigned|commission|normalizedDescription
  • normalizedDescriptiondescription после trim.
  • Суммы подставляются в том виде, в котором пришли в JSON (числовое представление).
  • Разделитель | исключает коллизии при склейке полей разной длины.

Результат хэширования хранится в поле transactions.fingerprint с префиксом sha256:.

Direction

Направление операции определяется по следующим правилам:

  1. Проверка ключевых фраз в description (регистронезависимо):
    • Если содержит одну из фраз: "Перевод между своими счетами", "перевод средств на счет", "Внутри ВТБ"direction = "transfer".
  2. Если ключевые фразы не сработали:
    • amountSigned > 0direction = "income";
    • amountSigned < 0direction = "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 = FALSEis_category_confirmed = TRUE.
  • Если у правила requires_confirmation = TRUEis_category_confirmed = FALSE.

Атомарность

Весь импорт выполняется в одной транзакции БД:

  • Создание счёта (если нужно) + вставка всех транзакций + автокатегоризация.
  • При любой ошибке валидации или вставки — откат всей транзакции БД, возвращается 422.

Успешный ответ (200 OK)

{
  "accountId": 1,
  "isNewAccount": true,
  "accountNumberMasked": "408178******5611",
  "imported": 28,
  "duplicatesSkipped": 3,
  "totalInFile": 31
}

Поля:

  • accountId: number — ID счёта в системе.
  • isNewAccount: booleantrue, если счёт был создан при этом импорте.
  • accountNumberMasked: string — маскированный номер счёта (первые 6 и последние 4 символа, остальное заменено на *).
  • imported: number — количество успешно импортированных (новых) транзакций.
  • duplicatesSkipped: number — количество пропущенных дубликатов.
  • totalInFile: number — общее количество транзакций в файле (imported + duplicatesSkipped).

Маскирование номера счёта

Номер счёта маскируется для отображения в UI:

  • Первые 6 символов остаются открытыми.
  • Последние 4 символа остаются открытыми.
  • Промежуточные символы заменяются фиксированным набором из 6 символов ****** (количество звёздочек не раскрывает длину номера).
  • Пример: 40817810825104025611408178******5611.

Маскирование применяется в ответе этого эндпоинта и в GET /api/accounts.

Коды ошибок

Код Ситуация
200 Импорт выполнен успешно
400 Файл не загружен; неверный тип (не PDF и не JSON); некорректный JSON; ошибка извлечения текста из PDF
401 Нет действующей сессии
422 Семантическая ошибка валидации; результат конвертации PDF не соответствует схеме 1.0
502 Ошибка LLM при конвертации PDF
503 Конвертация PDF недоступна (не задан LLM_API_KEY)