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

223 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Импорт выписки (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) |