213 lines
10 KiB
Markdown
213 lines
10 KiB
Markdown
# Импорт выписки (POST /api/import/statement)
|
||
|
||
## Назначение
|
||
|
||
Эндпоинт принимает банковскую выписку в формате JSON 1.0 (см. `format.md`) и атомарно импортирует транзакции в БД.
|
||
|
||
## Метод и URL
|
||
|
||
- Метод: `POST`
|
||
- URL: `/api/import/statement`
|
||
|
||
## Требования к авторизации
|
||
|
||
- Требуется действующая сессия (cookie `sid`).
|
||
- При отсутствии или недействительной сессии — `401 Unauthorized`.
|
||
|
||
## Тело запроса
|
||
|
||
JSON строго по схеме 1.0 (`format.md`). Content-Type: `application/json`.
|
||
|
||
```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 символа остаются открытыми.
|
||
- Промежуточные символы заменяются на `*`.
|
||
- Пример: `40817810825104025611` → `408178**********5611`.
|
||
|
||
Маскирование применяется в ответе этого эндпоинта и в `GET /api/accounts`.
|
||
|
||
## Коды ошибок
|
||
|
||
| Код | Ситуация |
|
||
|-----|--------------------------------------------------------------------------|
|
||
| 200 | Импорт выполнен успешно |
|
||
| 400 | Невалидный JSON или нарушение структуры схемы 1.0 |
|
||
| 401 | Нет действующей сессии |
|
||
| 422 | Семантическая ошибка валидации (некорректные данные, ошибка при вставке) |
|